From 9aef6f8ccd399aacaf0d5fb3b511b80f0c6ca17a Mon Sep 17 00:00:00 2001 From: Pasha Date: Fri, 20 Dec 2024 17:21:04 +0000 Subject: [PATCH 01/25] fix(js): add missing on click event for dropdown tabs (#7342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paweł Tymczuk --- packages/js/src/ui/components/InboxTabs/InboxTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/js/src/ui/components/InboxTabs/InboxTab.tsx b/packages/js/src/ui/components/InboxTabs/InboxTab.tsx index 5a06862eee2..57be770db48 100644 --- a/packages/js/src/ui/components/InboxTabs/InboxTab.tsx +++ b/packages/js/src/ui/components/InboxTabs/InboxTab.tsx @@ -53,6 +53,7 @@ export const InboxDropdownTab = (props: InboxDropdownTabProps) => { return ( {props.label} {props.rightIcon} From 9453294b9cb9c621b0e9b285fec57fac5806d781 Mon Sep 17 00:00:00 2001 From: Lucky <14868134+L-U-C-K-Y@users.noreply.github.com> Date: Sun, 22 Dec 2024 09:09:18 +0100 Subject: [PATCH 02/25] fix(api): fix onesignal ios_badgeCount and ios_badgeType typos (#7273) --- .../one-signal/one-signal.provider.spec.ts | 18 ++++++++---------- .../lib/push/one-signal/one-signal.provider.ts | 4 ++-- .../one-signal/one-signal.providerV2.spec.ts | 7 +++---- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/providers/src/lib/push/one-signal/one-signal.provider.spec.ts b/packages/providers/src/lib/push/one-signal/one-signal.provider.spec.ts index 84c9e6ab7f4..5482673d23b 100644 --- a/packages/providers/src/lib/push/one-signal/one-signal.provider.spec.ts +++ b/packages/providers/src/lib/push/one-signal/one-signal.provider.spec.ts @@ -44,7 +44,7 @@ describe('test onesignal notification api', () => { const spy = vi.spyOn(provider, 'sendMessage'); const res = await provider.sendMessage(mockNotificationOptions, { - iosBadgeCount: 2, + iosBadgeCount: 1, includeExternalUserIds: ['test'], }); expect(mockedAxios.request).toHaveBeenCalled(); @@ -59,14 +59,13 @@ describe('test onesignal notification api', () => { contents: { en: 'Test push' }, subtitle: {}, data: { sound: 'test_sound' }, - ios_badge_type: 'Increase', - ios_badgeCount: 2, - ios_badge_count: 1, + ios_badgeType: 'Increase', + ios_badgeCount: 1, include_external_user_ids: ['test'], }); expect(spy).toHaveBeenCalledWith(mockNotificationOptions, { - iosBadgeCount: 2, + iosBadgeCount: 1, includeExternalUserIds: ['test'], }); expect(res.id).toEqual(response.data.id); @@ -89,7 +88,7 @@ describe('test onesignal notification api', () => { const spy = vi.spyOn(provider, 'sendMessage'); const res = await provider.sendMessage(mockNotificationOptions, { - iosBadgeCount: 2, + iosBadgeCount: 1, includeExternalUserIds: ['test'], _passthrough: { body: { @@ -109,14 +108,13 @@ describe('test onesignal notification api', () => { contents: { en: 'Test push' }, subtitle: {}, data: { sound: 'test_sound' }, - ios_badge_type: 'Increase', - ios_badgeCount: 2, - ios_badge_count: 1, + ios_badgeType: 'Increase', + ios_badgeCount: 1, include_external_user_ids: ['test', 'test1'], }); expect(spy).toHaveBeenCalledWith(mockNotificationOptions, { - iosBadgeCount: 2, + iosBadgeCount: 1, includeExternalUserIds: ['test'], _passthrough: { body: { diff --git a/packages/providers/src/lib/push/one-signal/one-signal.provider.ts b/packages/providers/src/lib/push/one-signal/one-signal.provider.ts index 46ba4f99690..e2825cafe03 100644 --- a/packages/providers/src/lib/push/one-signal/one-signal.provider.ts +++ b/packages/providers/src/lib/push/one-signal/one-signal.provider.ts @@ -63,8 +63,8 @@ export class OneSignalPushProvider contents: { en: options.content }, subtitle: { en: overrides.subtitle }, data: options.payload, - ios_badge_type: 'Increase', - ios_badge_count: 1, + ios_badgeType: 'Increase', + ios_badgeCount: 1, ios_sound: sound, android_sound: sound, mutable_content: overrides.mutableContent, diff --git a/packages/providers/src/lib/push/one-signal/one-signal.providerV2.spec.ts b/packages/providers/src/lib/push/one-signal/one-signal.providerV2.spec.ts index 0713a9c3d4e..d8e74387743 100644 --- a/packages/providers/src/lib/push/one-signal/one-signal.providerV2.spec.ts +++ b/packages/providers/src/lib/push/one-signal/one-signal.providerV2.spec.ts @@ -45,7 +45,7 @@ describe('test onesignal notification user api', () => { const spy = vi.spyOn(provider, 'sendMessage'); const res = await provider.sendMessage(mockNotificationOptions, { - iosBadgeCount: 2, + iosBadgeCount: 1, }); expect(mockedAxios.request).toHaveBeenCalled(); const data = JSON.parse( @@ -62,9 +62,8 @@ describe('test onesignal notification user api', () => { contents: { en: 'Test push' }, subtitle: {}, data: { sound: 'test_sound' }, - ios_badge_type: 'Increase', - ios_badgeCount: 2, - ios_badge_count: 1, + ios_badgeType: 'Increase', + ios_badgeCount: 1, }); expect(res.id).toEqual(response.data.id); From eadaad40b648000940c6239d9d0b3ba3a3dd551d Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Sun, 22 Dec 2024 13:00:37 +0200 Subject: [PATCH 03/25] fix(framework): Remove @novu/shared dependency temporarily (#7337) Co-authored-by: Dima Grossman --- packages/framework/package.json | 1 - .../src/resources/workflow/map-preferences.ts | 2 +- .../src/schemas/providers/chat/index.ts | 2 +- .../src/schemas/providers/email/index.ts | 2 +- .../src/schemas/providers/inApp/index.ts | 2 +- .../src/schemas/providers/push/index.ts | 2 +- .../src/schemas/providers/sms/index.ts | 2 +- packages/framework/src/shared.ts | 252 ++++++++++++++++++ .../framework/src/types/discover.types.ts | 2 +- packages/framework/src/types/event.types.ts | 13 +- packages/framework/src/utils/http.utils.ts | 2 +- pnpm-lock.yaml | 3 - 12 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 packages/framework/src/shared.ts diff --git a/packages/framework/package.json b/packages/framework/package.json index 75a65225a39..128226ea998 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -232,7 +232,6 @@ "zod-to-json-schema": "^3.23.3" }, "dependencies": { - "@novu/shared": "workspace:*", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "better-ajv-errors": "^1.2.0", diff --git a/packages/framework/src/resources/workflow/map-preferences.ts b/packages/framework/src/resources/workflow/map-preferences.ts index 8ae1ea6dd9f..c2c58586f6e 100644 --- a/packages/framework/src/resources/workflow/map-preferences.ts +++ b/packages/framework/src/resources/workflow/map-preferences.ts @@ -1,4 +1,4 @@ -import { ChannelTypeEnum, WorkflowPreferencesPartial } from '@novu/shared'; +import { ChannelTypeEnum, WorkflowPreferencesPartial } from '../../shared'; import { WorkflowChannelEnum } from '../../constants'; import { WorkflowPreferences } from '../../types'; diff --git a/packages/framework/src/schemas/providers/chat/index.ts b/packages/framework/src/schemas/providers/chat/index.ts index ded0651ad23..a126953093e 100644 --- a/packages/framework/src/schemas/providers/chat/index.ts +++ b/packages/framework/src/schemas/providers/chat/index.ts @@ -1,4 +1,4 @@ -import { ChatProviderIdEnum } from '@novu/shared'; +import { ChatProviderIdEnum } from '../../../shared'; import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { slackProviderSchemas } from './slack.schema'; diff --git a/packages/framework/src/schemas/providers/email/index.ts b/packages/framework/src/schemas/providers/email/index.ts index c831020b4ac..2415e496ec5 100644 --- a/packages/framework/src/schemas/providers/email/index.ts +++ b/packages/framework/src/schemas/providers/email/index.ts @@ -1,4 +1,4 @@ -import { EmailProviderIdEnum } from '@novu/shared'; +import { EmailProviderIdEnum } from '../../../shared'; import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { mailgunProviderSchemas } from './mailgun.schema'; diff --git a/packages/framework/src/schemas/providers/inApp/index.ts b/packages/framework/src/schemas/providers/inApp/index.ts index d88a4df1331..9450f73b311 100644 --- a/packages/framework/src/schemas/providers/inApp/index.ts +++ b/packages/framework/src/schemas/providers/inApp/index.ts @@ -1,4 +1,4 @@ -import { InAppProviderIdEnum } from '@novu/shared'; +import { InAppProviderIdEnum } from '../../../shared'; import type { JsonSchema } from '../../../types/schema.types'; import { novuInAppProviderSchemas } from './novu-inapp.schema'; diff --git a/packages/framework/src/schemas/providers/push/index.ts b/packages/framework/src/schemas/providers/push/index.ts index 7a7a66cc6f6..5b63ff0b7be 100644 --- a/packages/framework/src/schemas/providers/push/index.ts +++ b/packages/framework/src/schemas/providers/push/index.ts @@ -1,4 +1,4 @@ -import { PushProviderIdEnum } from '@novu/shared'; +import { PushProviderIdEnum } from '../../../shared'; import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { apnsProviderSchemas } from './apns.schema'; diff --git a/packages/framework/src/schemas/providers/sms/index.ts b/packages/framework/src/schemas/providers/sms/index.ts index c782d592f21..c473a9858d7 100644 --- a/packages/framework/src/schemas/providers/sms/index.ts +++ b/packages/framework/src/schemas/providers/sms/index.ts @@ -1,4 +1,4 @@ -import { SmsProviderIdEnum } from '@novu/shared'; +import { SmsProviderIdEnum } from '../../../shared'; import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { novuSmsProviderSchemas } from './novu-sms.schema'; diff --git a/packages/framework/src/shared.ts b/packages/framework/src/shared.ts new file mode 100644 index 00000000000..e8c9b0f5bf3 --- /dev/null +++ b/packages/framework/src/shared.ts @@ -0,0 +1,252 @@ +/** + * ========== + * | NOTICE | + * ========== + * + * This file contains copied code from @novu/shared in order to temporarily eliminate the dependency of + * framework on the shared package. + * + * The shared package, doesn't support ESM/CJS with strict TS yet. + * So we sacrificed a bit code duplication in order to address ESM/CJS issues reported on the @novu/framework + * caused by its @novu/shared dependency. + * + * Treat this as a temporary solution until the shared package is updated with the above. + * + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface IResponseError { + error: string; + message: string; + statusCode: number; +} + +/** + * Validate (type-guard) that an error response matches our IResponseError interface. + */ +export function checkIsResponseError(err: unknown): err is IResponseError { + return !!err && typeof err === 'object' && 'error' in err && 'message' in err && 'statusCode' in err; +} + +export enum ChannelTypeEnum { + IN_APP = 'in_app', + EMAIL = 'email', + SMS = 'sms', + CHAT = 'chat', + PUSH = 'push', +} + +export interface IAttachmentOptions { + mime: string; + file: Buffer; + name?: string; + channels?: ChannelTypeEnum[]; + cid?: string; + disposition?: string; +} + +export interface ITriggerPayload { + attachments?: IAttachmentOptions[]; + [key: string]: + | string + | string[] + | boolean + | number + | undefined + | IAttachmentOptions + | IAttachmentOptions[] + | Record; +} + +export interface ISubscriberPayload { + subscriberId: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + avatar?: string; + locale?: string; + data?: Record; + channels?: ISubscriberChannel[]; +} + +export interface ISubscriberChannel { + providerId: ChatProviderIdEnum | PushProviderIdEnum; + integrationIdentifier?: string; + credentials: IChannelCredentials; +} + +export interface IChannelCredentials { + webhookUrl?: string; + deviceTokens?: string[]; +} + +export interface ITopic { + type: 'topic'; + topicKey: string; +} + +export type TriggerRecipientsPayload = string | ISubscriberPayload | ITopic | ISubscriberPayload[] | ITopic[]; + +export enum TriggerEventStatusEnum { + ERROR = 'error', + NOT_ACTIVE = 'trigger_not_active', + NO_WORKFLOW_ACTIVE_STEPS = 'no_workflow_active_steps_defined', + NO_WORKFLOW_STEPS = 'no_workflow_steps_defined', + PROCESSED = 'processed', + // TODO: Seems not used. Remove. + SUBSCRIBER_MISSING = 'subscriber_id_missing', + TENANT_MISSING = 'no_tenant_found', +} + +export enum EmailProviderIdEnum { + EmailJS = 'emailjs', + Mailgun = 'mailgun', + Mailjet = 'mailjet', + Mandrill = 'mandrill', + CustomSMTP = 'nodemailer', + Postmark = 'postmark', + SendGrid = 'sendgrid', + Sendinblue = 'sendinblue', + SES = 'ses', + NetCore = 'netcore', + Infobip = 'infobip-email', + Resend = 'resend', + Plunk = 'plunk', + MailerSend = 'mailersend', + Mailtrap = 'mailtrap', + Clickatell = 'clickatell', + Outlook365 = 'outlook365', + Novu = 'novu-email', + SparkPost = 'sparkpost', + EmailWebhook = 'email-webhook', + Braze = 'braze', +} + +export enum SmsProviderIdEnum { + Nexmo = 'nexmo', + Plivo = 'plivo', + Sms77 = 'sms77', + SmsCentral = 'sms-central', + SNS = 'sns', + Telnyx = 'telnyx', + Twilio = 'twilio', + Gupshup = 'gupshup', + Firetext = 'firetext', + Infobip = 'infobip-sms', + BurstSms = 'burst-sms', + BulkSms = 'bulk-sms', + ISendSms = 'isend-sms', + Clickatell = 'clickatell', + FortySixElks = 'forty-six-elks', + Kannel = 'kannel', + Maqsam = 'maqsam', + Termii = 'termii', + AfricasTalking = 'africas-talking', + Novu = 'novu-sms', + Sendchamp = 'sendchamp', + GenericSms = 'generic-sms', + Clicksend = 'clicksend', + Bandwidth = 'bandwidth', + MessageBird = 'messagebird', + Simpletexting = 'simpletexting', + AzureSms = 'azure-sms', + RingCentral = 'ring-central', + BrevoSms = 'brevo-sms', + EazySms = 'eazy-sms', + Mobishastra = 'mobishastra', +} + +export enum ChatProviderIdEnum { + Slack = 'slack', + Discord = 'discord', + MsTeams = 'msteams', + Mattermost = 'mattermost', + Ryver = 'ryver', + Zulip = 'zulip', + GrafanaOnCall = 'grafana-on-call', + GetStream = 'getstream', + RocketChat = 'rocket-chat', + WhatsAppBusiness = 'whatsapp-business', +} + +export enum PushProviderIdEnum { + FCM = 'fcm', + APNS = 'apns', + EXPO = 'expo', + OneSignal = 'one-signal', + Pushpad = 'pushpad', + PushWebhook = 'push-webhook', + PusherBeams = 'pusher-beams', +} + +export enum InAppProviderIdEnum { + Novu = 'novu', +} + +/** + * A preference for a notification delivery workflow. + * + * This provides a shortcut to setting all channels to the same preference. + */ +export type WorkflowPreference = { + /** + * A flag specifying if notification delivery is enabled for the workflow. + * + * If `true`, notification delivery is enabled by default for all channels. + * + * This setting can be overridden by the channel preferences. + * + * @default true + */ + enabled: boolean; + /** + * A flag specifying if the preference is read-only. + * + * If `true`, the preference cannot be changed by the Subscriber. + * + * @default false + */ + readOnly: boolean; +}; + +/** A preference for a notification delivery channel. */ +export type ChannelPreference = { + /** + * A flag specifying if notification delivery is enabled for the channel. + * + * If `true`, notification delivery is enabled. + * + * @default true + */ + enabled: boolean; +}; + +export type WorkflowPreferences = { + /** + * A preference for the workflow. + * + * The values specified here will be used if no preference is specified for a channel. + */ + all: WorkflowPreference; + /** + * A preference for each notification delivery channel. + * + * If no preference is specified for a channel, the `all` preference will be used. + */ + channels: Record; +}; + +/** + * Recursively make all properties of type `T` optional. + */ +// TODO: This utility also exists in src/types/util.types.ts. They should be consolidated. +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +/** A partial set of workflow preferences. */ +export type WorkflowPreferencesPartial = DeepPartial; diff --git a/packages/framework/src/types/discover.types.ts b/packages/framework/src/types/discover.types.ts index 91b67dcacbd..886e6f4162a 100644 --- a/packages/framework/src/types/discover.types.ts +++ b/packages/framework/src/types/discover.types.ts @@ -1,4 +1,4 @@ -import type { WorkflowPreferencesPartial } from '@novu/shared'; +import type { WorkflowPreferencesPartial } from '../shared'; import { ActionStepEnum, ChannelStepEnum } from '../constants'; import type { JsonSchema, Schema } from './schema.types'; import type { StepOptions } from './step.types'; diff --git a/packages/framework/src/types/event.types.ts b/packages/framework/src/types/event.types.ts index 23772a9f8cf..491b3d77fa1 100644 --- a/packages/framework/src/types/event.types.ts +++ b/packages/framework/src/types/event.types.ts @@ -1,16 +1,11 @@ -import type { - ITriggerPayload, - TriggerEventStatusEnum, - TriggerRecipientsPayload, - TriggerRecipientSubscriber, -} from '@novu/shared'; +import type { ISubscriberPayload, ITriggerPayload, TriggerEventStatusEnum, TriggerRecipientsPayload } from '../shared'; import { ConditionalPartial, PickRequiredKeys } from './util.types'; -type EventPayload = ITriggerPayload & {}; +type EventPayload = ITriggerPayload; -type Actor = TriggerRecipientSubscriber & {}; +type Actor = string | ISubscriberPayload; -type Recipients = TriggerRecipientsPayload & {}; +type Recipients = TriggerRecipientsPayload; export type EventTriggerResult = { /** diff --git a/packages/framework/src/utils/http.utils.ts b/packages/framework/src/utils/http.utils.ts index 361e57c5cfa..d8e9e5a914c 100644 --- a/packages/framework/src/utils/http.utils.ts +++ b/packages/framework/src/utils/http.utils.ts @@ -1,4 +1,4 @@ -import { checkIsResponseError } from '@novu/shared'; +import { checkIsResponseError } from '../shared'; import { BridgeError, MissingSecretKeyError, PlatformError } from '../errors'; export const initApiClient = (secretKey: string, apiUrl: string) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d15d57d79f..3fc10d4618b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3541,9 +3541,6 @@ importers: packages/framework: dependencies: - '@novu/shared': - specifier: workspace:* - version: link:../shared ajv: specifier: ^8.12.0 version: 8.12.0 From 2cd99fafe9533299e93052758ce7209da50ac7cb Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 22 Dec 2024 20:12:20 +0200 Subject: [PATCH 04/25] fix(root): pointer to next branch on ee --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 047015b573f..dfa9f193b6a 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 047015b573f6767edc0e40628b39e4023dded98f +Subproject commit dfa9f193b6a312a84cc562f68080a526d401bc21 From a207e522b368d63827a03bac8c8ec34a4573a15c Mon Sep 17 00:00:00 2001 From: GalTidhar <39020298+tatarco@users.noreply.github.com> Date: Mon, 23 Dec 2024 09:19:11 +0100 Subject: [PATCH 05/25] fix(api): @novu/api -> @novu/api-service (#7348) Co-authored-by: Dima Grossman --- .cspell.json | 1 + .devcontainer/devcontainer.json | 2 +- .github/actions/run-api/action.yml | 2 +- .github/actions/validate-openapi/action.yml | 2 +- .github/labeler.yml | 2 +- .github/workflows/jarvis.yml | 2 +- .github/workflows/on-pr.yml | 6 +++--- .github/workflows/reusable-dashboard-e2e.yml | 2 +- .github/workflows/reusable-web-e2e.yml | 2 +- .github/workflows/reusable-widget-e2e.yml | 2 +- apps/api/README.md | 2 +- apps/api/jarvis-api-intro.md | 4 ++-- apps/api/package.json | 4 ++-- eslint.config.mjs | 1 - novu.code-workspace | 2 +- nx.json | 2 +- package.json | 8 ++++---- pnpm-lock.yaml | 10 +++++----- scripts/jarvis.js | 12 ++++++------ 19 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.cspell.json b/.cspell.json index 8263f1a4114..0ae84732f00 100644 --- a/.cspell.json +++ b/.cspell.json @@ -12,6 +12,7 @@ "sdkerror", "africas", "africastalking", + "preferencechannels", "Aland", "alanturing", "alexjoverm", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ac586cbe668..459b5e5adfd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [4200, 3000, 27017], - "onCreateCommand": "npm run setup:project -- --exclude=@novu/api,@novu/worker,@novu/web,@novu/widget", + "onCreateCommand": "npm run setup:project -- --exclude=@novu/api-service,@novu/worker,@novu/web,@novu/widget", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node", diff --git a/.github/actions/run-api/action.yml b/.github/actions/run-api/action.yml index 93eaedc7a4d..a456ffc1771 100644 --- a/.github/actions/run-api/action.yml +++ b/.github/actions/run-api/action.yml @@ -15,7 +15,7 @@ runs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/api' + projects: '@novu/api-service' - name: Start API shell: bash diff --git a/.github/actions/validate-openapi/action.yml b/.github/actions/validate-openapi/action.yml index e5726ea88b4..0ba09170e55 100644 --- a/.github/actions/validate-openapi/action.yml +++ b/.github/actions/validate-openapi/action.yml @@ -11,7 +11,7 @@ runs: PORT: '1336' with: targets: lint:openapi - projects: '@novu/api' + projects: '@novu/api-service' - name: Kill port for api 1336 for unit tests shell: bash diff --git a/.github/labeler.yml b/.github/labeler.yml index 5211a4d8bcf..c653727ac91 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,4 +1,4 @@ -'@novu/api': +'@novu/api-service': - apps/api/**/* '@novu/worker': - apps/worker/**/* diff --git a/.github/workflows/jarvis.yml b/.github/workflows/jarvis.yml index b5144c331b4..4c74bba6854 100644 --- a/.github/workflows/jarvis.yml +++ b/.github/workflows/jarvis.yml @@ -5,7 +5,7 @@ on: - labeled jobs: add-comment: - if: github.event.label.name == '@novu/api' + if: github.event.label.name == '@novu/api-service' runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 13a0bb0a328..6549d4c4025 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -205,7 +205,7 @@ jobs: - uses: ./.github/actions/setup-project with: # Don't run redis and etc... for other unit tests - slim: ${{ !contains(matrix.projectName, '@novu/api') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') && !contains(matrix.projectName, '@novu/inbound-mail')}} + slim: ${{ !contains(matrix.projectName, '@novu/api-service') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') && !contains(matrix.projectName, '@novu/inbound-mail')}} - uses: ./.github/actions/setup-redis-cluster - uses: mansagroup/nrwl-nx-action@v3 name: Lint and build and test @@ -244,8 +244,8 @@ jobs: uses: ./.github/workflows/reusable-api-e2e.yml with: ee: ${{ contains (matrix.name,'-ee') }} - test-e2e-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api') || contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/worker') }} - test-e2e-ee-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/api') || contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/worker') }} + test-e2e-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api-service') || contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/worker') }} + test-e2e-ee-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/api-service') || contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/worker') }} job-name: ${{ matrix.name }} test-unit: false secrets: inherit diff --git a/.github/workflows/reusable-dashboard-e2e.yml b/.github/workflows/reusable-dashboard-e2e.yml index d0572ecb838..432e32e53cb 100644 --- a/.github/workflows/reusable-dashboard-e2e.yml +++ b/.github/workflows/reusable-dashboard-e2e.yml @@ -71,7 +71,7 @@ jobs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/dashboard,@novu/api,@novu/worker' + projects: '@novu/dashboard,@novu/api-service,@novu/worker' args: --skip-nx-cache - uses: ./.github/actions/start-localstack diff --git a/.github/workflows/reusable-web-e2e.yml b/.github/workflows/reusable-web-e2e.yml index b18a7557df2..db7be206abd 100644 --- a/.github/workflows/reusable-web-e2e.yml +++ b/.github/workflows/reusable-web-e2e.yml @@ -71,7 +71,7 @@ jobs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/web,@novu/api,@novu/worker' + projects: '@novu/web,@novu/api-service,@novu/worker' args: --skip-nx-cache - uses: ./.github/actions/start-localstack diff --git a/.github/workflows/reusable-widget-e2e.yml b/.github/workflows/reusable-widget-e2e.yml index 16e004b2856..d17b2a378c2 100644 --- a/.github/workflows/reusable-widget-e2e.yml +++ b/.github/workflows/reusable-widget-e2e.yml @@ -58,7 +58,7 @@ jobs: with: targets: build args: --skip-nx-cache - projects: '@novu/widget,@novu/embed,@novu/api,@novu/worker,@novu/ws' + projects: '@novu/widget,@novu/embed,@novu/api-service,@novu/worker,@novu/ws' - uses: ./.github/actions/run-backend with: diff --git a/apps/api/README.md b/apps/api/README.md index bee04293f68..0771a59ac1d 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -7,7 +7,7 @@ -# @novu/api +# @novu/api-service A RESTful API for accessing the Novu platform, built using [NestJS](https://nestjs.com/). diff --git a/apps/api/jarvis-api-intro.md b/apps/api/jarvis-api-intro.md index 5ec0272cb3b..1aba8e1edd9 100644 --- a/apps/api/jarvis-api-intro.md +++ b/apps/api/jarvis-api-intro.md @@ -3,7 +3,7 @@ Hi, I'm Jarvis 🤖 I'm a bot built to help you with your contribution to Novu. I will add instructions and guides on how to run the subset of the Novu platform associated to this issue and make your first contribution. -This issue was tagged as related to `@novu/api` and the related code is located at the `apps/api` folder, here is how I can help you: +This issue was tagged as related to `@novu/api-service` and the related code is located at the `apps/api` folder, here is how I can help you:
First time contributing to Novu? @@ -17,7 +17,7 @@ If that's the first time you want to contribute to Novu here are a few simple st
- Run and test `@novu/api` locally + Run and test `@novu/api-service` locally ### Run API in watch mode diff --git a/apps/api/package.json b/apps/api/package.json index 5f90527eb27..2969b2af2ea 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,5 +1,5 @@ { - "name": "@novu/api", + "name": "@novu/api-service", "version": "2.1.1", "description": "description", "author": "", @@ -42,7 +42,7 @@ "@nestjs/swagger": "7.4.0", "@nestjs/terminus": "10.2.3", "@nestjs/throttler": "6.2.1", - "@novu/api": "0.0.1-alpha.109", + "@novu/api": "0.0.1-alpha.149", "@novu/application-generic": "workspace:*", "@novu/dal": "workspace:*", "@novu/framework": "workspace:*", diff --git a/eslint.config.mjs b/eslint.config.mjs index a3eef606f13..361bb97d41b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,7 +43,6 @@ const noRestrictedImportsMultiLevelNovuPattern = { group: [ '@novu/*/**/*', '!@novu/api/**/*', // This allows all imports from @novu/api - // '!@novu/api/funcs/triggerBulk', // These packages have legitimate exports 1 path part below the root level // This flatMap logic ignores the path 1 below the root level and prevents deeper imports. ...['framework', 'js', 'novui'].flatMap((pkg) => [`!@novu/${pkg}/**/*`, `@novu/${pkg}/*/**/*`]), diff --git a/novu.code-workspace b/novu.code-workspace index 71b928ad811..6014850fc68 100644 --- a/novu.code-workspace +++ b/novu.code-workspace @@ -5,7 +5,7 @@ "path": ".", }, { - "name": "🚀 @novu/api", + "name": "🚀 @novu/api-service", "path": "apps/api", }, { diff --git a/nx.json b/nx.json index ebadc5f9821..d7894d4c746 100644 --- a/nx.json +++ b/nx.json @@ -26,7 +26,7 @@ "groups": { "apps": { "projects": [ - "@novu/api", + "@novu/api-service", "@novu/dashboard", "@novu/inbound-mail", "@novu/web", diff --git a/package.json b/package.json index 20983d0793c..992616b4411 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "bootstrap": "npm run setup:dev", "build-ee": "nx run-many --target=build-ee --all", - "build:api": "nx build @novu/api", + "build:api": "nx build @novu/api-service", "build:dashboard": "nx build @novu/dashboard", "build:embed": "nx build @novu/embed", "build:inbound-mail": "nx build @novu/inbound-mail", @@ -44,9 +44,9 @@ "release": "node scripts/release.mjs", "release:version:apps": "nx release version --projects=tag:type:app", "setup:project": "npx --yes pnpm@9.11.0 i && node scripts/setup-env-files.js && pnpm build", - "start:api:dev": "cross-env nx run @novu/api:start:dev", - "start:api:test": "cross-env nx run-many --target=start:test --projects=@novu/api", - "start:api": "cross-env nx run @novu/api:start", + "start:api:dev": "cross-env nx run @novu/api-service:start:dev", + "start:api:test": "cross-env nx run-many --target=start:test --projects=@novu/api-service", + "start:api": "cross-env nx run @novu/api-service:start", "start:dal": "cross-env nx run @novu/dal:start", "start:dashboard": "cross-env nx run @novu/dashboard:start", "start:dev": "cross-env lerna run start:dev --stream --parallel --concurrency=20 --scope=@novu/{api,worker,web,widget,ws,notification-center}", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fc10d4618b..00e47404f5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,8 +394,8 @@ importers: specifier: 6.2.1 version: 6.2.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1)(@nestjs/websockets@10.4.1)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2) '@novu/api': - specifier: 0.0.1-alpha.109 - version: 0.0.1-alpha.109(zod@3.23.8) + specifier: 0.0.1-alpha.149 + version: 0.0.1-alpha.149(zod@3.23.8) '@novu/application-generic': specifier: workspace:* version: link:../../libs/application-generic @@ -10610,8 +10610,8 @@ packages: '@nothing-but/utils@0.12.1': resolution: {integrity: sha512-1qZU1Q5El0IjE7JT/ucvJNzdr2hL3W8Rm27xNf1p6gb3Nw8pGnZmxp6/GEW9h+I1k1cICxXNq25hBwknTQ7yhg==} - '@novu/api@0.0.1-alpha.109': - resolution: {integrity: sha512-+fbdpvrhgzzRmdo6wh/gQG8whqSreTDGdkoW8N44sevTcMqg2LwQQs7E2hXkOcmBWUkyuZ7bbCPzMrf5uoKgfw==} + '@novu/api@0.0.1-alpha.149': + resolution: {integrity: sha512-HNLhe6+xsQGMnTD/EkaXumHuHcRyuu6c6yv987D7xjZa4CBXMyqi5rqQrCAUVs2go8+8WrqjoHEeTPHk5AmAoA==} peerDependencies: zod: '>= 3' @@ -47017,7 +47017,7 @@ snapshots: '@nothing-but/utils@0.12.1': {} - '@novu/api@0.0.1-alpha.109(zod@3.23.8)': + '@novu/api@0.0.1-alpha.149(zod@3.23.8)': dependencies: zod: 3.23.8 diff --git a/scripts/jarvis.js b/scripts/jarvis.js index 4d350901bf0..196a4dc9d29 100644 --- a/scripts/jarvis.js +++ b/scripts/jarvis.js @@ -96,7 +96,7 @@ async function setupRunner() { if (answers.action === DEV_ENVIRONMENT_SETUP) { shell.exec('npm run dev-environment-setup'); } else if (answers.runConfiguration === WEB_PROJECT_AND_WIDGET) { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker'); shell.exec('npm run start:dev', { async: true }); await waitPort({ @@ -127,7 +127,7 @@ async function setupRunner() { `); } else if (answers.runConfiguration === WEB_PROJECT) { try { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker,@novu/ws'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker,@novu/ws'); shell.exec('npm run start:api', { async: true }); shell.exec('npm run start:ws', { async: true }); @@ -162,7 +162,7 @@ async function setupRunner() { console.error(`Failed to spin up the project ❌`, e); } } else if (answers.runConfiguration === API_AND_WORKER_ONLY) { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker'); shell.exec('npm run start:api', { async: true }); shell.exec('npm run start:worker', { async: true }); @@ -182,7 +182,7 @@ async function setupRunner() { Worker: http://127.0.0.1:3004 `); } else if (answers.runApiConfiguration === API_INTEGRATION_TESTS) { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker'); shell.exec('npm run start:worker:test', { async: true }); await waitPort({ @@ -192,7 +192,7 @@ async function setupRunner() { shell.exec('npm run start:integration:api', { async: true }); } else if (answers.runApiConfiguration === API_E2E_TESTS) { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker'); shell.exec('npm run start:worker:test', { async: true }); await waitPort({ @@ -202,7 +202,7 @@ async function setupRunner() { shell.exec('npm run start:e2e:api', { async: true }); } else if ([RUN_PLAYWRIGHT_CLI, RUN_PLAYWRIGHT_UI].includes(answers.runWebConfiguration)) { - shell.exec('nx run-many --target=build --projects=@novu/api,@novu/worker,@novu/ws'); + shell.exec('nx run-many --target=build --projects=@novu/api-service,@novu/worker,@novu/ws'); shell.exec('cd apps/web && npm run build:test'); shell.exec('npm run start:api:test', { async: true }); From 49ad28099b3f8781d758db6acd7b7a3364acb8be Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Mon, 23 Dec 2024 10:28:18 +0200 Subject: [PATCH 06/25] chore(root): Upgrade Maily --- apps/api/package.json | 2 +- apps/dashboard/package.json | 2 +- pnpm-lock.yaml | 325 ++++++++---------------------------- 3 files changed, 70 insertions(+), 259 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 2969b2af2ea..f8a3af14e1b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,7 +32,7 @@ "dependencies": { "@godaddy/terminus": "^4.12.1", "@google-cloud/storage": "^6.2.3", - "@maily-to/render": "^0.0.16", + "@maily-to/render": "^0.0.17", "@nestjs/axios": "3.0.3", "@nestjs/common": "10.4.1", "@nestjs/core": "10.4.1", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b996b2e27e5..ccbdb35797e 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -25,7 +25,7 @@ "@codemirror/autocomplete": "^6.18.3", "@hookform/resolvers": "^3.9.0", "@lezer/highlight": "^1.2.1", - "@maily-to/core": "^0.0.18", + "@maily-to/core": "^0.0.19", "@novu/framework": "workspace:*", "@novu/js": "workspace:*", "@novu/react": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00e47404f5d..e0cdf38e3f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,8 +364,8 @@ importers: specifier: ^6.2.3 version: 6.9.5(encoding@0.1.13) '@maily-to/render': - specifier: ^0.0.16 - version: 0.0.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.0.17 + version: 0.0.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@nestjs/axios': specifier: 3.0.3 version: 3.0.3(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.6.8)(rxjs@7.8.1) @@ -678,8 +678,8 @@ importers: specifier: ^1.2.1 version: 1.2.1 '@maily-to/core': - specifier: ^0.0.18 - version: 0.0.18(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/node@22.7.4)(typescript@5.6.2))(y-prosemirror@1.2.15(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.20))(yjs@13.6.20)) + specifier: ^0.0.19 + version: 0.0.19(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/node@22.7.4)(typescript@5.6.2))(y-prosemirror@1.2.15(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.20))(yjs@13.6.20)) '@novu/framework': specifier: workspace:* version: link:../../packages/framework @@ -1122,13 +1122,13 @@ importers: dependencies: '@babel/plugin-proposal-optional-chaining': specifier: ^7.20.7 - version: 7.21.0(@babel/core@7.22.11) + version: 7.21.0(@babel/core@7.25.2) '@babel/plugin-transform-react-display-name': specifier: ^7.18.6 - version: 7.18.6(@babel/core@7.22.11) + version: 7.18.6(@babel/core@7.25.2) '@babel/plugin-transform-runtime': specifier: ^7.23.2 - version: 7.23.2(@babel/core@7.22.11) + version: 7.23.2(@babel/core@7.25.2) '@clerk/clerk-react': specifier: ^5.15.1 version: 5.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1453,13 +1453,13 @@ importers: version: 7.12.1 '@babel/preset-env': specifier: ^7.23.2 - version: 7.23.2(@babel/core@7.22.11) + version: 7.23.2(@babel/core@7.25.2) '@babel/preset-react': specifier: ^7.13.13 - version: 7.18.6(@babel/core@7.22.11) + version: 7.18.6(@babel/core@7.25.2) '@babel/preset-typescript': specifier: ^7.13.0 - version: 7.21.4(@babel/core@7.22.11) + version: 7.21.4(@babel/core@7.25.2) '@babel/runtime': specifier: ^7.20.13 version: 7.21.0 @@ -1504,13 +1504,13 @@ importers: version: 7.4.2 '@storybook/preset-create-react-app': specifier: ^7.4.2 - version: 7.4.2(ucmnrhmq4kewpo24xrp57f5r6y) + version: 7.4.2(joi3nebcpxx5275442gow2nj4e) '@storybook/react': specifier: ^7.4.2 version: 7.4.2(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) '@storybook/react-webpack5': specifier: ^7.4.2 - version: 7.4.2(@babel/core@7.22.11)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1) + version: 7.4.2(@babel/core@7.25.2)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1) '@testing-library/jest-dom': specifier: ^4.2.4 version: 4.2.4 @@ -1540,13 +1540,13 @@ importers: version: 4.1.0(less@4.1.3)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) react-app-rewired: specifier: ^2.2.1 - version: 2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1)) + version: 2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1)) react-error-overlay: specifier: 6.0.11 version: 6.0.11 react-scripts: specifier: ^5.0.1 - version: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) + version: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) sinon: specifier: 9.2.4 version: 9.2.4 @@ -9895,14 +9895,14 @@ packages: resolution: {integrity: sha512-SaNFseFPSDQlOYM9JTyYY6wauMu6qJ8eExo+jssFyb20ZaVvxKX1eTb3Gm5aW/4aWuxn6nofU+02sCk51//wdw==} engines: {node: '>=10.0.0'} - '@maily-to/core@0.0.18': - resolution: {integrity: sha512-Ny+7kFDmLn0pZsE7/xcBJV2hhOXjtWccL5svZrtI0Md1WWsvcpO4Nib0GZVIbR+D28rHbYKYEu0LRn/PXxeKRg==} + '@maily-to/core@0.0.19': + resolution: {integrity: sha512-FppQl3Wb2iwxxKMBoHmaoCwYiIyVwZxygbEA7qCAaTiHFZjvZIvifA8toU5xvL5Z5SEUtjkLe9Q3UQUzSnS1aQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.3.1 - '@maily-to/render@0.0.16': - resolution: {integrity: sha512-zskDYnGazUjp4diAdR7dQAAPCCW3fvyGCFFdn3jVbwKx0CNvi0r1uHSrytQ4gG6IGC7CCa84mkEe3JohS5WXhQ==} + '@maily-to/render@0.0.17': + resolution: {integrity: sha512-vFDlY9U9XtyDRzeVGVON9tgbzBRrq5wBOMG1aFGc/30soaamK7oYxRqSUbwNmrfT2ScBgL7YFtDLoeRZbpdmeA==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.3.1 @@ -38611,17 +38611,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-compilation-targets': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 - debug: 4.3.6(supports-color@8.1.1) - lodash.debounce: 4.0.8 - resolve: 1.22.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.24.3)': dependencies: '@babel/core': 7.24.3 @@ -39214,13 +39203,6 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.21.4) - '@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.11) - '@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -39413,11 +39395,6 @@ snapshots: '@babel/core': 7.21.4 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-flow@7.24.7(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -39538,11 +39515,6 @@ snapshots: '@babel/core': 7.21.4 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -39558,11 +39530,6 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.24.3)': dependencies: '@babel/core': 7.24.3 @@ -39783,11 +39750,6 @@ snapshots: '@babel/core': 7.21.4 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.24.4)': dependencies: '@babel/core': 7.24.4 @@ -40362,12 +40324,6 @@ snapshots: '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-flow': 7.22.5(@babel/core@7.21.4) - '@babel/plugin-transform-flow-strip-types@7.25.2(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.22.11) - '@babel/plugin-transform-flow-strip-types@7.25.2(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -40606,15 +40562,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.22.15(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-module-transforms': 7.25.2(@babel/core@7.22.11) - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-simple-access': 7.22.5 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-modules-commonjs@7.22.15(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -41185,9 +41132,9 @@ snapshots: '@babel/core': 7.21.4 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.22.11)': + '@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.25.2)': dependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-transform-react-display-name@7.22.5(@babel/core@7.21.4)': @@ -41195,11 +41142,6 @@ snapshots: '@babel/core': 7.21.4 '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-transform-react-display-name@7.22.5(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-transform-react-display-name@7.22.5(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -41222,17 +41164,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.22.11) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-jsx-development@7.22.5(@babel/core@7.22.11)': + '@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.25.2)': dependencies: - '@babel/core': 7.22.11 - '@babel/plugin-transform-react-jsx': 7.22.15(@babel/core@7.22.11) + '@babel/core': 7.25.2 + '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.25.2) transitivePeerDependencies: - supports-color @@ -41298,28 +41233,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.22.11)': + '@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.25.2)': dependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.25.2 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-module-imports': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.11) + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.25.2) '@babel/types': 7.22.19 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.22.15(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.11) - '@babel/types': 7.25.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-react-jsx@7.22.15(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -41353,17 +41277,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.22.11) - '@babel/types': 7.25.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.24.3)': dependencies: '@babel/core': 7.24.3 @@ -41392,18 +41305,12 @@ snapshots: '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.22.11)': + '@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.25.2)': dependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.25.2 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-react-pure-annotations@7.22.5(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-react-pure-annotations@7.22.5(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -41489,18 +41396,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-runtime@7.23.2(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.8 - babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.22.11) - babel-plugin-polyfill-corejs3: 0.8.5(@babel/core@7.22.11) - babel-plugin-polyfill-regenerator: 0.5.3(@babel/core@7.22.11) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-runtime@7.23.2(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -41647,16 +41542,6 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-typescript@7.21.3(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-create-class-features-plugin': 7.22.15(@babel/core@7.22.11) - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.22.11) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-typescript@7.21.3(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -42250,13 +42135,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-flow@7.22.15(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-validator-option': 7.24.8 - '@babel/plugin-transform-flow-strip-types': 7.25.2(@babel/core@7.22.11) - '@babel/preset-flow@7.22.15(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 @@ -42325,27 +42203,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-react@7.18.6(@babel/core@7.22.11)': + '@babel/preset-react@7.18.6(@babel/core@7.25.2)': dependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.20.2 '@babel/helper-validator-option': 7.21.0 - '@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.22.11) - '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.22.11) - '@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.22.11) - '@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.22.11) - transitivePeerDependencies: - - supports-color - - '@babel/preset-react@7.22.15(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-validator-option': 7.22.15 - '@babel/plugin-transform-react-display-name': 7.22.5(@babel/core@7.22.11) - '@babel/plugin-transform-react-jsx': 7.22.15(@babel/core@7.22.11) - '@babel/plugin-transform-react-jsx-development': 7.22.5(@babel/core@7.22.11) - '@babel/plugin-transform-react-pure-annotations': 7.22.5(@babel/core@7.22.11) + '@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.25.2) + '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.25.2) + '@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.25.2) + '@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.25.2) transitivePeerDependencies: - supports-color @@ -42385,17 +42251,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.21.4(@babel/core@7.22.11)': - dependencies: - '@babel/core': 7.22.11 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-validator-option': 7.22.15 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.11) - '@babel/plugin-transform-modules-commonjs': 7.22.15(@babel/core@7.22.11) - '@babel/plugin-transform-typescript': 7.21.3(@babel/core@7.22.11) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.21.4(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -46120,7 +45975,7 @@ snapshots: transitivePeerDependencies: - debug - '@maily-to/core@0.0.18(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/node@22.7.4)(typescript@5.6.2))(y-prosemirror@1.2.15(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.20))(yjs@13.6.20))': + '@maily-to/core@0.0.19(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/node@22.7.4)(typescript@5.6.2))(y-prosemirror@1.2.15(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.20))(yjs@13.6.20))': dependencies: '@radix-ui/react-dropdown-menu': 2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -46163,7 +46018,7 @@ snapshots: - ts-node - y-prosemirror - '@maily-to/render@0.0.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@maily-to/render@0.0.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@react-email/components': 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-email/render': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -54939,7 +54794,7 @@ snapshots: get-port: 5.1.1 giget: 1.1.2 globby: 11.1.0 - jscodeshift: 0.14.0(@babel/preset-env@7.23.2(@babel/core@7.22.11)) + jscodeshift: 0.14.0(@babel/preset-env@7.23.2(@babel/core@7.25.2)) leven: 3.1.0 ora: 5.4.1 prettier: 2.8.8 @@ -55510,16 +55365,16 @@ snapshots: '@storybook/postinstall@7.4.2': {} - '@storybook/preset-create-react-app@7.4.2(ucmnrhmq4kewpo24xrp57f5r6y)': + '@storybook/preset-create-react-app@7.4.2(joi3nebcpxx5275442gow2nj4e)': dependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.25.2 '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.2)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) '@storybook/types': 7.4.2 '@types/babel__core': 7.20.0 babel-plugin-react-docgen: 4.2.1 pnp-webpack-plugin: 1.7.0(typescript@5.6.2) - react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) + react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) semver: 7.5.4 transitivePeerDependencies: - '@types/webpack' @@ -55533,16 +55388,16 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/preset-react-webpack@7.4.2(@babel/core@7.22.11)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)': + '@storybook/preset-react-webpack@7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1)': dependencies: - '@babel/preset-flow': 7.22.15(@babel/core@7.22.11) - '@babel/preset-react': 7.22.15(@babel/core@7.22.11) - '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) + '@babel/preset-flow': 7.22.15(@babel/core@7.23.2) + '@babel/preset-react': 7.22.15(@babel/core@7.23.2) + '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12))) '@storybook/core-webpack': 7.4.2(encoding@0.1.13) '@storybook/docs-tools': 7.4.2(encoding@0.1.13) '@storybook/node-logger': 7.4.2 '@storybook/react': 7.4.2(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.2)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.2)(webpack@5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12))) '@types/node': 16.11.7 '@types/semver': 7.5.8 babel-plugin-add-react-displayname: 0.0.5 @@ -55552,9 +55407,9 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-refresh: 0.11.0 semver: 7.6.3 - webpack: 5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20) + webpack: 5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12)) optionalDependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.23.2 typescript: 5.6.2 transitivePeerDependencies: - '@swc/core' @@ -55570,16 +55425,16 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/preset-react-webpack@7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1)': + '@storybook/preset-react-webpack@7.4.2(@babel/core@7.25.2)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)': dependencies: - '@babel/preset-flow': 7.22.15(@babel/core@7.23.2) - '@babel/preset-react': 7.22.15(@babel/core@7.23.2) - '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12))) + '@babel/preset-flow': 7.22.15(@babel/core@7.25.2) + '@babel/preset-react': 7.22.15(@babel/core@7.25.2) + '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) '@storybook/core-webpack': 7.4.2(encoding@0.1.13) '@storybook/docs-tools': 7.4.2(encoding@0.1.13) '@storybook/node-logger': 7.4.2 '@storybook/react': 7.4.2(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.2)(webpack@5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12))) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.6.2)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) '@types/node': 16.11.7 '@types/semver': 7.5.8 babel-plugin-add-react-displayname: 0.0.5 @@ -55589,9 +55444,9 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-refresh: 0.11.0 semver: 7.6.3 - webpack: 5.78.0(@swc/core@1.7.26(@swc/helpers@0.5.12)) + webpack: 5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20) optionalDependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.25.2 typescript: 5.6.2 transitivePeerDependencies: - '@swc/core' @@ -55794,16 +55649,16 @@ snapshots: - vite-plugin-glimmerx - webpack-sources - '@storybook/react-webpack5@7.4.2(@babel/core@7.22.11)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)': + '@storybook/react-webpack5@7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1)': dependencies: - '@storybook/builder-webpack5': 7.4.2(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) - '@storybook/preset-react-webpack': 7.4.2(@babel/core@7.22.11)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1) + '@storybook/builder-webpack5': 7.4.2(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) + '@storybook/preset-react-webpack': 7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1) '@storybook/react': 7.4.2(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) '@types/node': 16.11.7 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@babel/core': 7.22.11 + '@babel/core': 7.23.2 typescript: 5.6.2 transitivePeerDependencies: - '@swc/core' @@ -55822,16 +55677,16 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/react-webpack5@7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1)': + '@storybook/react-webpack5@7.4.2(@babel/core@7.25.2)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)': dependencies: - '@storybook/builder-webpack5': 7.4.2(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) - '@storybook/preset-react-webpack': 7.4.2(@babel/core@7.23.2)(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.7.26(@swc/helpers@0.5.12)))(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-hot-middleware@2.26.1) + '@storybook/builder-webpack5': 7.4.2(@swc/helpers@0.5.12)(@types/react-dom@18.3.0)(@types/react@18.3.3)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) + '@storybook/preset-react-webpack': 7.4.2(@babel/core@7.25.2)(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@2.19.0)(typescript@5.6.2)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1) '@storybook/react': 7.4.2(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.2) '@types/node': 16.11.7 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.25.2 typescript: 5.6.2 transitivePeerDependencies: - '@swc/core' @@ -60828,15 +60683,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.22.11): - dependencies: - '@babel/compat-data': 7.25.4 - '@babel/core': 7.22.11 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.22.11) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.24.3): dependencies: '@babel/compat-data': 7.25.4 @@ -64659,33 +64505,6 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(jest@27.5.1(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2)))(typescript@5.6.2): - dependencies: - '@babel/core': 7.21.4 - '@babel/eslint-parser': 7.25.1(@babel/core@7.21.4)(eslint@9.9.1(jiti@1.21.6)) - '@rushstack/eslint-patch': 1.2.0 - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) - '@typescript-eslint/parser': 5.62.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) - babel-preset-react-app: 10.0.1 - confusing-browser-globals: 1.0.11 - eslint: 9.9.1(jiti@1.21.6) - eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(eslint@9.9.1(jiti@1.21.6)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6)) - eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint@9.9.1(jiti@1.21.6))(jest@27.5.1(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2)))(typescript@5.6.2) - eslint-plugin-jsx-a11y: 6.9.0(eslint@9.9.1(jiti@1.21.6)) - eslint-plugin-react: 7.35.0(eslint@9.9.1(jiti@1.21.6)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.9.1(jiti@1.21.6)) - eslint-plugin-testing-library: 5.10.2(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - transitivePeerDependencies: - - '@babel/plugin-syntax-flow' - - '@babel/plugin-transform-react-jsx' - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - jest - - supports-color - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(jest@27.5.1(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2)))(typescript@5.6.2): dependencies: '@babel/core': 7.21.4 @@ -64794,14 +64613,6 @@ snapshots: eslint: 8.57.1 ignore: 5.3.2 - eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(eslint@9.9.1(jiti@1.21.6)): - dependencies: - '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.22.11) - '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.22.11) - eslint: 9.9.1(jiti@1.21.6) - lodash: 4.17.21 - string-natural-compare: 3.0.1 - eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(eslint@9.9.1(jiti@1.21.6)): dependencies: '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.25.2) @@ -69876,7 +69687,7 @@ snapshots: jsbn@0.1.1: {} - jscodeshift@0.14.0(@babel/preset-env@7.23.2(@babel/core@7.22.11)): + jscodeshift@0.14.0(@babel/preset-env@7.23.2(@babel/core@7.25.2)): dependencies: '@babel/core': 7.25.2 '@babel/parser': 7.25.6 @@ -69884,7 +69695,7 @@ snapshots: '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.25.2) '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.25.2) '@babel/plugin-transform-modules-commonjs': 7.24.8(@babel/core@7.25.2) - '@babel/preset-env': 7.23.2(@babel/core@7.22.11) + '@babel/preset-env': 7.23.2(@babel/core@7.25.2) '@babel/preset-flow': 7.24.7(@babel/core@7.25.2) '@babel/preset-typescript': 7.23.2(@babel/core@7.25.2) '@babel/register': 7.21.0(@babel/core@7.25.2) @@ -76523,9 +76334,9 @@ snapshots: regenerator-runtime: 0.13.11 whatwg-fetch: 3.6.2 - react-app-rewired@2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1)): + react-app-rewired@2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1)): dependencies: - react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) + react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1) semver: 5.7.2 react-app-rewired@2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12)))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1)): @@ -76960,7 +76771,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1): + react-scripts@5.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/babel__core@7.20.5)(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(esbuild@0.18.20)(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(react@18.3.1)(sass@1.77.8)(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2))(type-fest@2.19.0)(typescript@5.6.2)(vue-template-compiler@2.7.16)(webpack-hot-middleware@2.26.1): dependencies: '@babel/core': 7.21.4 '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(@types/webpack@5.28.5(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20))(react-refresh@0.11.0)(type-fest@2.19.0)(webpack-dev-server@4.11.1(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)))(webpack-hot-middleware@2.26.1)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) @@ -76978,7 +76789,7 @@ snapshots: dotenv: 10.0.0 dotenv-expand: 5.1.0 eslint: 9.9.1(jiti@1.21.6) - eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.22.11))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.22.11))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(jest@27.5.1(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2)))(typescript@5.6.2) + eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.2))(@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.1)(webpack@5.94.0(@swc/core@1.3.107(@swc/helpers@0.5.12))))(eslint@9.9.1(jiti@1.21.6))(jest@27.5.1(ts-node@10.9.1(@swc/core@1.3.107(@swc/helpers@0.5.12))(@types/node@18.16.9)(typescript@5.6.2)))(typescript@5.6.2) eslint-webpack-plugin: 3.2.0(eslint@9.9.1(jiti@1.21.6))(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) file-loader: 6.2.0(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.12))(esbuild@0.18.20)) fs-extra: 10.1.0 From d7fe69c92aa04b6bf373d8ea2cde3030116aeb10 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 10:52:34 +0200 Subject: [PATCH 07/25] fix(dashboard): minor ui glitches --- .../src/components/auth/region-picker.tsx | 34 ++++++++++--------- .../steps/email/email-editor-preview.tsx | 18 +++++----- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/components/auth/region-picker.tsx b/apps/dashboard/src/components/auth/region-picker.tsx index 04b17fa75fe..75632034220 100644 --- a/apps/dashboard/src/components/auth/region-picker.tsx +++ b/apps/dashboard/src/components/auth/region-picker.tsx @@ -26,10 +26,10 @@ export function RegionPicker() { function handleRegionChange(value: RegionType) { switch (value) { case REGION_MAP.US: - window.location.href = 'https://dashboard.novu.co'; + window.location.href = 'https://dashboard-v2.novu.co'; break; case REGION_MAP.EU: - window.location.href = 'https://eu.dashboard.novu.co'; + window.location.href = 'https://eu.dashboard-v2.novu.co'; break; } } @@ -50,20 +50,22 @@ export function RegionPicker() { - +
+ +
); } diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx index a8ccfc745d0..bdcf1fcb5e7 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx @@ -69,14 +69,16 @@ export const EmailEditorPreview = ({ workflow, step, formValues }: EmailEditorPr - - - - - - - - +
+ + + + + + + + +
{isPreviewPending ? ( From 10f71f3c220c357d45ecc54be60ead8c3e3f28f4 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 11:02:32 +0200 Subject: [PATCH 08/25] fix(dsahboard): pending refetch state for test workflow --- .../test-workflow/test-workflow-logs-sidebar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx index a8df615967e..905ece44650 100644 --- a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx +++ b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx @@ -30,7 +30,13 @@ export const TestWorkflowLogsSidebar = ({ transactionId }: TestWorkflowLogsSideb if (!activities?.length) return; const activity = activities[0]; - const isPending = activity.jobs?.some((job) => job.status === JobStatusEnum.PENDING); + const isPending = activity.jobs?.some( + (job) => + job.status === JobStatusEnum.PENDING || + job.status === JobStatusEnum.QUEUED || + job.status === JobStatusEnum.RUNNING || + job.status === JobStatusEnum.DELAYED + ); // Only stop refetching if we have an activity and it's not pending setShouldRefetch(isPending); From aa6478008c622196297ba571d2efc488e5ff3993 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Mon, 23 Dec 2024 11:27:46 +0200 Subject: [PATCH 09/25] refactor(api-service): preview usecase (#7344) --- apps/api/package.json | 2 + .../delay-output-renderer.usecase.ts | 13 +- .../digest-output-renderer.usecase.ts | 4 +- .../hydrate-email-schema.usecase.ts | 9 +- .../in-app-output-renderer.usecase.ts | 8 +- .../render-email-output.usecase.ts | 38 +- .../app/workflows-v2/generate-preview.e2e.ts | 12 +- apps/api/src/app/workflows-v2/shared/index.ts | 1 - .../shared/sanitize-control-values.ts | 249 +++++++++++ .../shared/schemas/chat-control.schema.ts | 19 +- .../shared/schemas/delay-control.schema.ts | 13 +- .../shared/schemas/digest-control.schema.ts | 29 +- .../shared/schemas/email-control.schema.ts | 19 +- .../shared/schemas/in-app-control.schema.ts | 16 +- .../app/workflows-v2/shared/schemas/index.ts | 2 - .../shared/schemas/push-control.schema.ts | 19 +- .../shared/schemas/skip-control.schema.ts | 5 - .../shared/schemas/sms-control.schema.ts | 19 +- .../shared/step-type-to-control.mapper.ts | 33 +- ...build-available-variable-schema.command.ts | 9 +- ...build-available-variable-schema.usecase.ts | 17 +- .../generate-preview.usecase.ts | 189 ++------ .../patch-step-data/patch-step.usecase.ts | 8 +- .../patch-workflow/patch-workflow.usecase.ts | 15 +- .../usecases/upsert-workflow/index.ts | 3 - ...preferences-request-upsert-data.command.ts | 37 -- .../upsert-step-data.command.ts | 21 - .../upsert-workflow-data.command.ts | 55 --- .../upsert-workflow.command.ts | 109 ++++- .../upsert-workflow.usecase.ts | 406 ++++++++++++++---- .../prepare-and-validate-content.usecase.ts | 52 +-- .../app/workflows-v2/util/build-variables.ts | 93 ++++ .../util/template-parser/liquid-parser.ts | 28 +- .../util/template-parser/parser-utils.ts | 2 +- .../workflows-v2/workflow.controller.e2e.ts | 37 +- .../usecases/add-job/add-job.usecase.ts | 7 +- libs/application-generic/package.json | 1 + .../tier-restrictions-validate.command.ts | 22 +- .../tier-restrictions-validate.consts.ts | 7 - .../tier-restrictions-validate.response.ts | 6 +- .../tier-restrictions-validate.usecase.ts | 207 ++++++--- libs/application-generic/src/utils/index.ts | 1 - .../utils/sanitize-preview-control-values.ts | 222 ---------- pnpm-lock.yaml | 9 + 44 files changed, 1192 insertions(+), 881 deletions(-) create mode 100644 apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts delete mode 100644 apps/api/src/app/workflows-v2/shared/schemas/index.ts delete mode 100644 apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts delete mode 100644 apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts delete mode 100644 apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts create mode 100644 apps/api/src/app/workflows-v2/util/build-variables.ts delete mode 100644 libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.consts.ts delete mode 100644 libs/application-generic/src/utils/sanitize-preview-control-values.ts diff --git a/apps/api/package.json b/apps/api/package.json index f8a3af14e1b..f6e3b3e67f6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -101,6 +101,8 @@ "twilio": "^4.14.1", "uuid": "^8.3.2", "zod": "^3.23.8", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "zod-to-json-schema": "^3.23.3" }, "devDependencies": { diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts index 67138cd0170..0d8b37bde98 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts @@ -2,21 +2,18 @@ import { Injectable } from '@nestjs/common'; import { DelayRenderOutput } from '@novu/shared'; import { InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; -import { - DelayTimeControlType, - DelayTimeControlZodSchema, -} from '../../../workflows-v2/shared/schemas/delay-control.schema'; +import { delayControlZodSchema, DelayControlType } from '../../../workflows-v2/shared/schemas/delay-control.schema'; @Injectable() export class DelayOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): DelayRenderOutput { - const delayTimeControlType: DelayTimeControlType = DelayTimeControlZodSchema.parse(renderCommand.controlValues); + const delayControlType: DelayControlType = delayControlZodSchema.parse(renderCommand.controlValues); return { - amount: delayTimeControlType.amount as number, - type: delayTimeControlType.type, - unit: delayTimeControlType.unit, + amount: delayControlType.amount as number, + type: delayControlType.type, + unit: delayControlType.unit, }; } } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts index 40db7bdc1a3..b7e44b3f826 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts @@ -4,7 +4,7 @@ import { InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; import { DigestControlSchemaType, - DigestControlZodSchema, + digestControlZodSchema, isDigestRegularControl, isDigestTimedControl, } from '../../../workflows-v2/shared/schemas/digest-control.schema'; @@ -13,7 +13,7 @@ import { export class DigestOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): DigestRenderOutput { - const parse: DigestControlSchemaType = DigestControlZodSchema.parse(renderCommand.controlValues); + const parse: DigestControlSchemaType = digestControlZodSchema.parse(renderCommand.controlValues); if ( isDigestRegularControl(parse) && parse.amount && diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts index 433f131b242..1d0f66fecac 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts @@ -117,10 +117,8 @@ export class HydrateEmailSchemaUseCase { node, placeholderAggregation: PlaceholderAggregation ) { - const { fallback } = node.attrs; - const variableName = node.attrs.id; - const buildLiquidJSDefault = (mailyFallback: string) => (mailyFallback ? ` | default: '${mailyFallback}'` : ''); - const finalValue = `{{ ${variableName} ${buildLiquidJSDefault(fallback)} }}`; + const { fallback, id: variableName } = node.attrs; + const finalValue = buildLiquidJSDefault(variableName, fallback); placeholderAggregation.regularPlaceholdersToDefaultValue[`{{${node.attrs.id}}}`] = finalValue; @@ -234,3 +232,6 @@ export const TipTapSchema = z.object({ text: z.string().optional(), attrs: z.record(z.unknown()).optional(), }); + +const buildLiquidJSDefault = (variableName: string, fallback?: string) => + `{{ ${variableName}${fallback ? ` | default: '${fallback}'` : ''} }}`; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts index a65b307bb86..71414a0dc22 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts @@ -3,19 +3,19 @@ import { InAppRenderOutput, RedirectTargetEnum } from '@novu/shared'; import { Injectable } from '@nestjs/common'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; +import { isValidUrlForActionButton } from '../../../workflows-v2/util/url-utils'; import { InAppActionType, InAppControlType, - InAppControlZodSchema, + inAppControlZodSchema, InAppRedirectType, -} from '../../../workflows-v2/shared'; -import { isValidUrlForActionButton } from '../../../workflows-v2/util/url-utils'; +} from '../../../workflows-v2/shared/schemas/in-app-control.schema'; @Injectable() export class InAppOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): InAppRenderOutput { - const inApp: InAppControlType = InAppControlZodSchema.parse(renderCommand.controlValues); + const inApp: InAppControlType = inAppControlZodSchema.parse(renderCommand.controlValues); if (!inApp) { throw new Error('Invalid in-app control value data'); } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index ccaf5a886a0..729b476a901 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -1,12 +1,14 @@ -import { EmailRenderOutput, TipTapNode } from '@novu/shared'; -import { Injectable } from '@nestjs/common'; import { render as mailyRender } from '@maily-to/render'; -import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import isEmpty from 'lodash/isEmpty'; +import { Injectable } from '@nestjs/common'; import { Liquid } from 'liquidjs'; + +import { EmailRenderOutput, TipTapNode } from '@novu/shared'; +import { Instrument, InstrumentUsecase } from '@novu/application-generic'; + import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { emailStepControlZodSchema } from '../../../workflows-v2/shared'; +import { emailControlZodSchema } from '../../../workflows-v2/shared/schemas/email-control.schema'; export class RenderEmailOutputCommand extends RenderCommand {} @@ -16,13 +18,13 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { body, subject } = emailStepControlZodSchema.parse(renderCommand.controlValues); + const { body, subject } = emailControlZodSchema.parse(renderCommand.controlValues); if (isEmpty(body)) { return { subject, body: '' }; } - const expandedMailyContent = this.transformForAndShowLogic(body, renderCommand.fullPayloadForRender); + const expandedMailyContent = this.transformMailyDynamicBlocks(body, renderCommand.fullPayloadForRender); const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand); const renderedHtml = await this.renderEmail(parsedTipTap); @@ -30,11 +32,15 @@ export class RenderEmailOutputUsecase { } private async parseTipTapNodeByLiquid( - value: TipTapNode, + tiptapNode: TipTapNode, renderCommand: RenderEmailOutputCommand ): Promise { - const client = new Liquid(); - const templateString = client.parse(JSON.stringify(value)); + const client = new Liquid({ + outputEscape: (output) => { + return stringifyDataStructureWithSingleQuotes(output); + }, + }); + const templateString = client.parse(JSON.stringify(tiptapNode)); const parsedTipTap = await client.render(templateString, { payload: renderCommand.fullPayloadForRender.payload, subscriber: renderCommand.fullPayloadForRender.subscriber, @@ -50,7 +56,19 @@ export class RenderEmailOutputUsecase { } @Instrument() - private transformForAndShowLogic(body: string, fullPayloadForRender: FullPayloadForRender) { + private transformMailyDynamicBlocks(body: string, fullPayloadForRender: FullPayloadForRender) { return this.expandEmailEditorSchemaUseCase.execute({ emailEditorJson: body, fullPayloadForRender }); } } + +export const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => { + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + const valueStringified = JSON.stringify(value, null, spaces); + const valueSingleQuotes = valueStringified.replace(/"/g, "'"); + const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + + return valueEscapedNewLines; + } else { + return String(value); + } +}; diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index eb51b367233..7bf69ec9cad 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -18,8 +18,8 @@ import { } from '@novu/shared'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; import { forSnippet, fullCodeSnippet } from './maily-test-data'; -import { InAppControlType } from './shared'; -import { EmailStepControlType } from './shared/schemas/email-control.schema'; +import { InAppControlType } from './shared/schemas/in-app-control.schema'; +import { EmailControlType } from './shared/schemas/email-control.schema'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; @@ -421,7 +421,7 @@ describe('Generate Preview', () => { channelTypes.forEach(({ type, description }) => { // TODO: We need to get back to the drawing board on this one to make the preview action of the framework more forgiving - it(`[${type}] catches the 400 error returned by the Bridge Preview action`, async () => { + it(`[${type}] will generate gracefully the preview if the control values are missing`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(workflowsClient, type); const requestDto = buildDtoWithMissingControlValues(type, stepId); @@ -433,7 +433,7 @@ describe('Generate Preview', () => { description ); - expect(previewResponseDto.result).to.eql({ preview: {} }); + expect(previewResponseDto.result).to.not.eql({ preview: {} }); }); }); }); @@ -514,13 +514,13 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): Generat }; } -function buildEmailControlValuesPayload(stepId?: string): EmailStepControlType { +function buildEmailControlValuesPayload(stepId?: string): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, body: JSON.stringify(fullCodeSnippet(stepId)), }; } -function buildSimpleForEmail(): EmailStepControlType { +function buildSimpleForEmail(): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, body: JSON.stringify(forSnippet), diff --git a/apps/api/src/app/workflows-v2/shared/index.ts b/apps/api/src/app/workflows-v2/shared/index.ts index 8693e9de430..5abd43fc690 100644 --- a/apps/api/src/app/workflows-v2/shared/index.ts +++ b/apps/api/src/app/workflows-v2/shared/index.ts @@ -1,3 +1,2 @@ export * from './step-type-to-control.mapper'; export * from './map-step-type-to-result.mapper'; -export * from './schemas'; diff --git a/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts b/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts new file mode 100644 index 00000000000..79a427df91b --- /dev/null +++ b/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts @@ -0,0 +1,249 @@ +import { StepTypeEnum, TimeUnitEnum } from '@novu/shared'; +import { isEmpty } from 'lodash'; +import { SmsControlType } from './schemas/sms-control.schema'; +import { ChatControlType } from './schemas/chat-control.schema'; +import { DelayControlType } from './schemas/delay-control.schema'; +import { + DigestControlSchemaType, + DigestRegularControlType, + DigestTimedControlType, + isDigestRegularControl, + isDigestTimedControl, +} from './schemas/digest-control.schema'; +import { PushControlType } from './schemas/push-control.schema'; +import { InAppControlType } from './schemas/in-app-control.schema'; +import { EmailControlType } from './schemas/email-control.schema'; + +const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: ' ' }], + }, + ], +}); +const WHITESPACE = ' '; + +type Redirect = { + url: string; + target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; +}; + +type Action = { + label?: string; + redirect?: Redirect; +}; + +type LookBackWindow = { + amount: number; + unit: string; +}; + +function sanitizeRedirect(redirect: Redirect | undefined) { + if (!redirect?.url || !redirect?.target) { + return undefined; + } + + return { + url: redirect.url || 'https://example.com', + target: redirect.target || '_self', + }; +} + +function sanitizeAction(action: Action) { + if (!action?.label) { + return undefined; + } + + return { + label: action.label, + redirect: sanitizeRedirect(action.redirect), + }; +} + +function sanitizeInApp(controlValues: InAppControlType) { + const normalized: InAppControlType = { + subject: controlValues.subject || undefined, + body: isEmpty(controlValues.body) ? WHITESPACE : controlValues.body, + avatar: controlValues.avatar || undefined, + primaryAction: null, + secondaryAction: null, + redirect: null, + data: controlValues.data || undefined, + skip: controlValues.skip || undefined, + }; + + if (controlValues.primaryAction) { + normalized.primaryAction = sanitizeAction(controlValues.primaryAction as Action); + } + + if (controlValues.secondaryAction) { + normalized.secondaryAction = sanitizeAction(controlValues.secondaryAction as Action); + } + + if (controlValues.redirect) { + normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); + } + + return filterNullishValues(normalized); +} + +function sanitizeEmail(controlValues: EmailControlType) { + const emailControls: EmailControlType = { + subject: controlValues.subject || '', + body: controlValues.body || EMPTY_TIP_TAP_OBJECT, + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(emailControls); +} + +function sanitizeSms(controlValues: SmsControlType) { + const mappedValues: SmsControlType = { + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizePush(controlValues: PushControlType) { + const mappedValues: PushControlType = { + subject: controlValues.subject || '', + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizeChat(controlValues: ChatControlType) { + const mappedValues: ChatControlType = { + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizeDigest(controlValues: DigestControlSchemaType) { + if (isDigestTimedControl(controlValues)) { + const mappedValues: DigestTimedControlType = { + cron: controlValues.cron || '', + digestKey: controlValues.digestKey || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); + } + + if (isDigestRegularControl(controlValues)) { + const mappedValues: DigestRegularControlType = { + amount: controlValues.amount || 0, + unit: controlValues.unit || TimeUnitEnum.SECONDS, + digestKey: controlValues.digestKey || '', + skip: controlValues.skip || undefined, + lookBackWindow: controlValues.lookBackWindow + ? { + amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: ((controlValues.lookBackWindow as LookBackWindow).unit as TimeUnitEnum) || TimeUnitEnum.SECONDS, + } + : undefined, + }; + + return filterNullishValues(mappedValues); + } + + const anyControlValues = controlValues as Record; + + return filterNullishValues({ + amount: anyControlValues.amount || 0, + unit: anyControlValues.unit || TimeUnitEnum.SECONDS, + digestKey: anyControlValues.digestKey || '', + skip: anyControlValues.skip || undefined, + lookBackWindow: anyControlValues.lookBackWindow + ? { + amount: (anyControlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: ((anyControlValues.lookBackWindow as LookBackWindow).unit as TimeUnitEnum) || TimeUnitEnum.SECONDS, + } + : undefined, + }); +} + +function sanitizeDelay(controlValues: DelayControlType) { + const mappedValues: DelayControlType = { + type: controlValues.type || 'regular', + amount: controlValues.amount || 0, + unit: controlValues.unit || TimeUnitEnum.SECONDS, + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function filterNullishValues>(obj: T): T { + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== null && value !== undefined)) as T; + } + + return obj; +} + +/** + * Sanitizes control values received from client-side forms into a clean minimal object. + * This function processes potentially invalid form data that may contain default/placeholder values + * and transforms it into a standardized format suitable for preview generation. + * + * @example + * // Input from form with default values: + * { + * subject: "Hello", + * body: null, + * unusedField: "test" + * } + * + * // Normalized output: + * { + * subject: "Hello", + * body: " " + * } + * + */ +export function sanitizeControlValues( + controlValues: Record, + stepType: StepTypeEnum +): Record | null { + if (!controlValues) { + return null; + } + let normalizedValues: Record; + switch (stepType) { + case StepTypeEnum.IN_APP: + normalizedValues = sanitizeInApp(controlValues as InAppControlType); + break; + case StepTypeEnum.EMAIL: + normalizedValues = sanitizeEmail(controlValues as EmailControlType); + break; + case StepTypeEnum.SMS: + normalizedValues = sanitizeSms(controlValues as SmsControlType); + break; + case StepTypeEnum.PUSH: + normalizedValues = sanitizePush(controlValues as PushControlType); + break; + case StepTypeEnum.CHAT: + normalizedValues = sanitizeChat(controlValues as ChatControlType); + break; + case StepTypeEnum.DIGEST: + normalizedValues = sanitizeDigest(controlValues as DigestControlSchemaType); + break; + case StepTypeEnum.DELAY: + normalizedValues = sanitizeDelay(controlValues as DelayControlType); + break; + default: + normalizedValues = filterNullishValues(controlValues); + } + + return normalizedValues; +} diff --git a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts index 4855b30e463..a05dd9e1021 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts @@ -2,29 +2,24 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; -export const ChatStepControlZodSchema = z +export const chatControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, body: z.string(), }) .strict(); -export type ChatStepControlType = z.infer; +export type ChatControlType = z.infer; -export const chatStepControlSchema = zodToJsonSchema(ChatStepControlZodSchema) as JSONSchemaDto; -export const chatStepUiSchema: UiSchema = { +export const chatControlSchema = zodToJsonSchema(chatControlZodSchema) as JSONSchemaDto; +export const chatUiSchema: UiSchema = { group: UiSchemaGroupEnum.CHAT, properties: { body: { component: UiComponentEnum.CHAT_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const chatStepControl = { - uiSchema: chatStepUiSchema, - schema: chatStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts index cd3976caa1f..73df64df4fb 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts @@ -8,25 +8,24 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; -export const DelayTimeControlZodSchema = z +export const delayControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, type: z.enum(['regular']).default('regular'), amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum), }) .strict(); -export const DelayTimeControlSchema = zodToJsonSchema(DelayTimeControlZodSchema) as JSONSchemaDto; - -export type DelayTimeControlType = z.infer; +export type DelayControlType = z.infer; +export const delayControlSchema = zodToJsonSchema(delayControlZodSchema) as JSONSchemaDto; export const delayUiSchema: UiSchema = { group: UiSchemaGroupEnum.DELAY, properties: { - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, amount: { component: UiComponentEnum.DELAY_AMOUNT, placeholder: null, diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index e8fc6e508e3..c9fc750cd52 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -8,12 +8,12 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; -const DigestRegularControlZodSchema = z +const digestRegularControlZodSchema = z .object({ + skip: skipZodSchema, amount: z.union([z.number().min(1), z.string().min(1)]), - skip: skipControl.schema, unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), digestKey: z.string().optional(), lookBackWindow: z @@ -25,38 +25,39 @@ const DigestRegularControlZodSchema = z .optional(), }) .strict(); - -const DigestTimedControlZodSchema = z +const digestTimedControlZodSchema = z .object({ + skip: skipZodSchema, cron: z.string().min(1), digestKey: z.string().optional(), }) .strict(); -export const DigestControlZodSchema = z.union([DigestRegularControlZodSchema, DigestTimedControlZodSchema]); +export type DigestRegularControlType = z.infer; +export type DigestTimedControlType = z.infer; +export type DigestControlSchemaType = z.infer; -export type DigestRegularControlType = z.infer; -export type DigestTimedControlType = z.infer; -export type DigestControlSchemaType = z.infer; +export const digestControlZodSchema = z.union([digestRegularControlZodSchema, digestTimedControlZodSchema]); +export const digestControlSchema = zodToJsonSchema(digestControlZodSchema) as JSONSchemaDto; -export const DigestOutputJsonSchema = zodToJsonSchema(DigestControlZodSchema) as JSONSchemaDto; export function isDigestRegularControl(data: unknown): data is DigestRegularControlType { - const result = DigestRegularControlZodSchema.safeParse(data); + const result = digestRegularControlZodSchema.safeParse(data); return result.success; } export function isDigestTimedControl(data: unknown): data is DigestTimedControlType { - const result = DigestTimedControlZodSchema.safeParse(data); + const result = digestTimedControlZodSchema.safeParse(data); return result.success; } export function isDigestControl(data: unknown): data is DigestControlSchemaType { - const result = DigestControlZodSchema.safeParse(data); + const result = digestControlZodSchema.safeParse(data); return result.success; } + export const digestUiSchema: UiSchema = { group: UiSchemaGroupEnum.DIGEST, properties: { @@ -76,6 +77,6 @@ export const digestUiSchema: UiSchema = { component: UiComponentEnum.DIGEST_CRON, placeholder: '', }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 897c4f88429..7cf7b21e093 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -1,12 +1,12 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -import { skipControl } from './skip-control.schema'; import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; +import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; -export const emailStepControlZodSchema = z +export const emailControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, /* * todo: we need to validate the email editor (body) by type and not string, * updating it to TipTapSchema will break the existing upsert issues generation @@ -16,10 +16,10 @@ export const emailStepControlZodSchema = z }) .strict(); -export const emailStepControlSchema = zodToJsonSchema(emailStepControlZodSchema) as JSONSchemaDto; -export type EmailStepControlType = z.infer; +export type EmailControlType = z.infer; -export const emailStepUiSchema: UiSchema = { +export const emailControlSchema = zodToJsonSchema(emailControlZodSchema) as JSONSchemaDto; +export const emailUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, properties: { body: { @@ -28,11 +28,6 @@ export const emailStepUiSchema: UiSchema = { subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const emailStepControl = { - uiSchema: emailStepUiSchema, - schema: emailStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts index fdd9da2533a..0d827963e74 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; const redirectZodSchema = z .object({ @@ -21,9 +21,9 @@ const actionZodSchema = z .optional() .nullable(); -export const InAppControlZodSchema = z +export const inAppControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, subject: z.string().optional(), body: z.string(), avatar: z.string().optional(), @@ -36,11 +36,11 @@ export const InAppControlZodSchema = z export type InAppRedirectType = z.infer; export type InAppActionType = z.infer; -export type InAppControlType = z.infer; +export type InAppControlType = z.infer; -export const InAppRedirectSchema = zodToJsonSchema(redirectZodSchema) as JSONSchemaDto; -export const InAppActionSchema = zodToJsonSchema(actionZodSchema) as JSONSchemaDto; -export const inAppControlSchema = zodToJsonSchema(InAppControlZodSchema) as JSONSchemaDto; +export const inAppRedirectSchema = zodToJsonSchema(redirectZodSchema) as JSONSchemaDto; +export const inAppActionSchema = zodToJsonSchema(actionZodSchema) as JSONSchemaDto; +export const inAppControlSchema = zodToJsonSchema(inAppControlZodSchema) as JSONSchemaDto; const redirectPlaceholder = { url: { @@ -78,6 +78,6 @@ export const inAppUiSchema: UiSchema = { component: UiComponentEnum.URL_TEXT_BOX, placeholder: redirectPlaceholder, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/index.ts b/apps/api/src/app/workflows-v2/shared/schemas/index.ts deleted file mode 100644 index 13aa930325e..00000000000 --- a/apps/api/src/app/workflows-v2/shared/schemas/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './email-control.schema'; -export * from './in-app-control.schema'; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts index c10e9f165d7..2ff49d9ec6b 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts @@ -2,20 +2,20 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; -export const PushStepControlZodSchema = z +export const pushControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, subject: z.string(), body: z.string(), }) .strict(); -export type PushStepControlType = z.infer; +export type PushControlType = z.infer; -export const pushStepControlSchema = zodToJsonSchema(PushStepControlZodSchema) as JSONSchemaDto; -export const pushStepUiSchema: UiSchema = { +export const pushControlSchema = zodToJsonSchema(pushControlZodSchema) as JSONSchemaDto; +export const pushUiSchema: UiSchema = { group: UiSchemaGroupEnum.PUSH, properties: { subject: { @@ -24,11 +24,6 @@ export const pushStepUiSchema: UiSchema = { body: { component: UiComponentEnum.PUSH_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const pushStepControl = { - uiSchema: pushStepUiSchema, - schema: pushStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts index 286bc032e3f..28fa246e337 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts @@ -11,8 +11,3 @@ export const skipStepUiSchema = { }, }, } satisfies UiSchema; - -export const skipControl = { - uiSchema: skipStepUiSchema, - schema: skipZodSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts index cefee696214..36ebe4ff5d7 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts @@ -2,29 +2,24 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; -export const SmsStepControlZodSchema = z +export const smsControlZodSchema = z .object({ - skip: skipControl.schema, + skip: skipZodSchema, body: z.string(), }) .strict(); -export type SmsStepControlType = z.infer; +export type SmsControlType = z.infer; -export const smsStepControlSchema = zodToJsonSchema(SmsStepControlZodSchema) as JSONSchemaDto; -export const smsStepUiSchema: UiSchema = { +export const smsControlSchema = zodToJsonSchema(smsControlZodSchema) as JSONSchemaDto; +export const smsUiSchema: UiSchema = { group: UiSchemaGroupEnum.SMS, properties: { body: { component: UiComponentEnum.SMS_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const smsStepControl = { - uiSchema: smsStepUiSchema, - schema: smsStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts index 663d6b10642..677c156354d 100644 --- a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts +++ b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts @@ -1,11 +1,12 @@ import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; import { ControlSchemas, JSONSchemaDto } from '@novu/shared'; -import { emailStepControl, inAppControlSchema, inAppUiSchema } from './schemas'; -import { DelayTimeControlSchema, delayUiSchema } from './schemas/delay-control.schema'; -import { DigestOutputJsonSchema, digestUiSchema } from './schemas/digest-control.schema'; -import { smsStepControl } from './schemas/sms-control.schema'; -import { chatStepControl } from './schemas/chat-control.schema'; -import { pushStepControl } from './schemas/push-control.schema'; +import { inAppControlSchema, inAppUiSchema } from './schemas/in-app-control.schema'; +import { emailControlSchema, emailUiSchema } from './schemas/email-control.schema'; +import { smsControlSchema, smsUiSchema } from './schemas/sms-control.schema'; +import { pushControlSchema, pushUiSchema } from './schemas/push-control.schema'; +import { chatControlSchema, chatUiSchema } from './schemas/chat-control.schema'; +import { delayControlSchema, delayUiSchema } from './schemas/delay-control.schema'; +import { digestControlSchema, digestUiSchema } from './schemas/digest-control.schema'; export const PERMISSIVE_EMPTY_SCHEMA = { type: 'object', @@ -20,27 +21,27 @@ export const stepTypeToControlSchema: Record; } diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts index aeaef56a4c5..54647d61040 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -14,9 +14,9 @@ export class BuildAvailableVariableSchemaUsecase { async execute(command: BuildAvailableVariableSchemaCommand): Promise { const { workflow } = command; - const previousSteps = workflow.steps.slice( + const previousSteps = workflow?.steps.slice( 0, - workflow.steps.findIndex((stepItem) => stepItem._id === command.stepInternalId) + workflow?.steps.findIndex((stepItem) => stepItem._id === command.stepInternalId) ); return { @@ -49,7 +49,7 @@ export class BuildAvailableVariableSchemaUsecase { required: ['firstName', 'lastName', 'email', 'subscriberId'], additionalProperties: false, }, - steps: buildPreviousStepsSchema(previousSteps, workflow.payloadSchema), + steps: buildPreviousStepsSchema(previousSteps, workflow?.payloadSchema), payload: await this.resolvePayloadSchema(workflow, command), }, additionalProperties: false, @@ -58,9 +58,17 @@ export class BuildAvailableVariableSchemaUsecase { @Instrument() private async resolvePayloadSchema( - workflow: NotificationTemplateEntity, + workflow: NotificationTemplateEntity | undefined, command: BuildAvailableVariableSchemaCommand ): Promise { + if (!workflow) { + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; + } + if (workflow.payloadSchema) { return ( parsePayloadSchema(workflow.payloadSchema, { safe: true }) || { @@ -77,6 +85,7 @@ export class BuildAvailableVariableSchemaUsecase { organizationId: command.organizationId, userId: command.userId, workflowId: workflow._id, + ...(command.optimisticControlValues ? { controlValues: command.optimisticControlValues } : {}), }) ); } diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts index 9040f57db17..c5bac8fb892 100644 --- a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts @@ -7,7 +7,6 @@ import { JobStatusEnum, PreviewPayload, StepDataDto, - TipTapNode, WorkflowOriginEnum, } from '@novu/shared'; import { @@ -17,7 +16,6 @@ import { Instrument, InstrumentUsecase, PinoLogger, - sanitizePreviewControlValues, } from '@novu/application-generic'; import { captureException } from '@sentry/node'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; @@ -26,36 +24,22 @@ import { BuildStepDataUsecase } from '../build-step-data'; import { GeneratePreviewCommand } from './generate-preview.command'; import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; -import { - extractLiquidTemplateVariables, - TemplateParseResult, - Variable, -} from '../../util/template-parser/liquid-parser'; +import { Variable } from '../../util/template-parser/liquid-parser'; import { pathsToObject } from '../../util/path-to-object'; -import { transformMailyContentToLiquid } from './transform-maily-content-to-liquid'; -import { isObjectTipTapNode, isStringTipTapNode } from '../../util/tip-tap.util'; +import { isObjectTipTapNode } from '../../util/tip-tap.util'; +import { buildVariables } from '../../util/build-variables'; +import { sanitizeControlValues } from '../../shared/sanitize-control-values'; const LOG_CONTEXT = 'GeneratePreviewUsecase'; -type DestructuredControlValues = { - tiptapControlValues: { emailEditor?: string | null; body?: string | null } | null; - // this is the remaining control values after the tiptap control is extracted - simpleControlValues: Record; -}; - -type ProcessedControlResult = { - controlValues: Record; - variablesExample: Record | null; -}; - @Injectable() export class GeneratePreviewUsecase { constructor( private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private readonly logger: PinoLogger, - private buildPayloadSchema: BuildPayloadSchema + private buildPayloadSchema: BuildPayloadSchema, + private readonly logger: PinoLogger ) {} @InstrumentUsecase() @@ -68,7 +52,7 @@ export class GeneratePreviewUsecase { workflow, } = await this.initializePreviewContext(command); const commandVariablesExample = command.generatePreviewRequestDto.previewPayload; - const sanitizedValidatedControls = sanitizePreviewControlValues(initialControlValues, stepData.type); + const sanitizedValidatedControls = sanitizeControlValues(initialControlValues, stepData.type); if (!sanitizedValidatedControls) { throw new Error( @@ -77,37 +61,25 @@ export class GeneratePreviewUsecase { ); } - let previewDataResult = { + let previewTemplateData = { variablesExample: {}, controlValues: {}, }; for (const [controlKey, controlValue] of Object.entries(sanitizedValidatedControls)) { - // previewControlValue is the control value that will be used to render the preview - const previewControlValue = controlValue; - // variableControlValue is the control value that will be used to extract the variables example - let variableControlValue = controlValue; - if (isStringTipTapNode(variableControlValue)) { - try { - variableControlValue = transformMailyContentToLiquid(JSON.parse(variableControlValue)); - } catch (error) { - console.log(error); - } - } + const variables = buildVariables(variableSchema, controlValue, this.logger); + const processedControlValues = this.fixControlValueInvalidVariables(controlValue, variables.invalidVariables); - const variables = this.processControlValueVariables(variableControlValue, variableSchema); - const processedControlValues = this.fixControlValueInvalidVariables(previewControlValue, variables.invalid); - - const validVariableNames = variables.valid.map((variable) => variable.name); + const validVariableNames = variables.validVariables.map((variable) => variable.name); const variablesExampleResult = pathsToObject(validVariableNames, { valuePrefix: '{{', valueSuffix: '}}', }); - previewDataResult = { - variablesExample: _.merge(previewDataResult.variablesExample, variablesExampleResult), + previewTemplateData = { + variablesExample: _.merge(previewTemplateData.variablesExample, variablesExampleResult), controlValues: { - ...previewDataResult.controlValues, + ...previewTemplateData.controlValues, [controlKey]: isObjectTipTapNode(processedControlValues) ? JSON.stringify(processedControlValues) : processedControlValues, @@ -115,12 +87,12 @@ export class GeneratePreviewUsecase { }; } - const finalVariablesExample = this.buildVariable(workflow, previewDataResult, commandVariablesExample); + const mergedVariablesExample = this.mergeVariablesExample(workflow, previewTemplateData, commandVariablesExample); const executeOutput = await this.executePreviewUsecase( command, stepData, - finalVariablesExample, - previewDataResult.controlValues + mergedVariablesExample, + previewTemplateData.controlValues ); return { @@ -128,7 +100,7 @@ export class GeneratePreviewUsecase { preview: executeOutput.outputs as any, type: stepData.type as unknown as ChannelTypeEnum, }, - previewPayloadExample: finalVariablesExample, + previewPayloadExample: mergedVariablesExample, }; } catch (error) { this.logger.error( @@ -154,9 +126,9 @@ export class GeneratePreviewUsecase { } } - private buildVariable( + private mergeVariablesExample( workflow: WorkflowInternalResponseDto, - previewDataResult: { variablesExample: {}; controlValues: {} }, + previewTemplateData: { variablesExample: {}; controlValues: {} }, commandVariablesExample: PreviewPayload | undefined ) { let finalVariablesExample = {}; @@ -166,9 +138,9 @@ export class GeneratePreviewUsecase { type: 'object', properties: { payload: workflow.payloadSchema }, }); - finalVariablesExample = { ...previewDataResult.variablesExample, ...tmp }; + finalVariablesExample = { ...previewTemplateData.variablesExample, ...tmp }; } else { - finalVariablesExample = previewDataResult.variablesExample; + finalVariablesExample = previewTemplateData.variablesExample; } finalVariablesExample = _.merge(finalVariablesExample, commandVariablesExample || {}); @@ -185,26 +157,6 @@ export class GeneratePreviewUsecase { return { stepData, controlValues, variableSchema, workflow }; } - private processControlValueVariables( - controlValue: unknown, - variableSchema: Record - ): { - valid: Variable[]; - invalid: Variable[]; - } { - const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(controlValue)); - - const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( - variableSchema, - validVariables - ); - - return { - valid: validSchemaVariables, - invalid: [...invalidVariables, ...invalidSchemaVariables], - }; - } - @Instrument() private async buildVariablesSchema( variables: Record, @@ -293,12 +245,12 @@ export class GeneratePreviewUsecase { let controlValuesString = JSON.stringify(controlValues); for (const invalidVariable of invalidVariables) { - if (!controlValuesString.includes(invalidVariable.template)) { + if (!controlValuesString.includes(invalidVariable.output)) { continue; } const EMPTY_STRING = ''; - controlValuesString = replaceAll(controlValuesString, invalidVariable.template, EMPTY_STRING); + controlValuesString = replaceAll(controlValuesString, invalidVariable.output, EMPTY_STRING); } return JSON.parse(controlValuesString) as Record; @@ -323,6 +275,13 @@ function buildState(steps: Record | undefined): FrameworkPrevio return outputArray; } +/** + * Replaces all occurrences of a search string with a replacement string. + */ +export function replaceAll(text: string, searchValue: string, replaceValue: string): string { + return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); +} + export class GeneratePreviewError extends InternalServerErrorException { constructor(error: FrameworkError) { super({ @@ -345,89 +304,3 @@ class FrameworkError { message: string; name: string; } - -/** - * Validates liquid template variables against a schema, the result is an object with valid and invalid variables - * @example - * const variables = [ - * { name: 'subscriber.firstName' }, - * { name: 'subscriber.orderId' } - * ]; - * const schema = { - * properties: { - * subscriber: { - * properties: { - * firstName: { type: 'string' } - * } - * } - * } - * }; - * const invalid = [{ name: 'unknown.variable' }]; - * - * validateVariablesAgainstSchema(variables, schema, invalid); - * // Returns: - * // { - * // validVariables: [{ name: 'subscriber.firstName' }], - * // invalidVariables: [{ name: 'unknown.variable' }, { name: 'subscriber.orderId' }] - * // } - */ -function identifyUnknownVariables( - variableSchema: Record, - validVariables: Variable[] -): TemplateParseResult { - const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); - - const result = validVariablesCopy.reduce( - (acc, variable: Variable) => { - const parts = variable.name.split('.'); - let isValid = true; - let currentPath = 'properties'; - - for (const part of parts) { - currentPath += `.${part}`; - const valueSearch = _.get(variableSchema, currentPath); - - currentPath += '.properties'; - const propertiesSearch = _.get(variableSchema, currentPath); - - if (valueSearch === undefined && propertiesSearch === undefined) { - isValid = false; - break; - } - } - - if (isValid) { - acc.validVariables.push(variable); - } else { - acc.invalidVariables.push({ - name: variable.template, - context: variable.context, - message: 'Variable is not supported', - template: variable.template, - }); - } - - return acc; - }, - { - validVariables: [] as Variable[], - invalidVariables: [] as Variable[], - } as TemplateParseResult - ); - - return result; -} - -/** - * Fixes invalid Liquid template variables for preview by replacing them with error messages. - * - * @example - * // Input controlValues: - * { "message": "Hello {{invalid.var}}" } - * - * // Output: - * { "message": "Hello [[Invalid Variable: invalid.var]]" } - */ -function replaceAll(text: string, searchValue: string, replaceValue: string): string { - return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); -} diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts index 02d8924a1a1..8420659db02 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts @@ -11,7 +11,6 @@ import { } from '@novu/application-generic'; import { PatchStepCommand } from './patch-step.command'; import { BuildStepDataUsecase } from '../build-step-data'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; type ValidNotificationWorkflow = { currentStep: NonNullable; @@ -24,7 +23,6 @@ export class PatchStepUsecase { private buildStepDataUsecase: BuildStepDataUsecase, private notificationTemplateRepository: NotificationTemplateRepository, private upsertControlValuesUseCase: UpsertControlValuesUseCase, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, @Inject(forwardRef(() => DeleteControlValuesUseCase)) private deleteControlValuesUseCase: DeleteControlValuesUseCase ) {} @@ -32,11 +30,7 @@ export class PatchStepUsecase { async execute(command: PatchStepCommand): Promise { const persistedItems = await this.loadPersistedItems(command); await this.patchFieldsOnPersistedItems(command, persistedItems); - const updatedWorkflow = await this.postProcessWorkflowUpdate.execute({ - workflow: persistedItems.workflow, - user: command.user, - }); - await this.persistWorkflow(updatedWorkflow, command.user); + await this.persistWorkflow(persistedItems.workflow, command.user); return await this.buildStepDataUsecase.execute(command); } diff --git a/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts index be67d748646..db45d81466e 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { UserSessionData, WorkflowResponseDto } from '@novu/shared'; +import { UserSessionData, WorkflowResponseDto, WorkflowStatusEnum } from '@novu/shared'; import { NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { GetWorkflowByIdsUseCase, WorkflowInternalResponseDto } from '@novu/application-generic'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; import { PatchWorkflowCommand } from './patch-workflow.command'; import { GetWorkflowUseCase } from '../get-workflow'; @@ -11,18 +10,12 @@ export class PatchWorkflowUsecase { constructor( private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private notificationTemplateRepository: NotificationTemplateRepository, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, private getWorkflowUseCase: GetWorkflowUseCase ) {} async execute(command: PatchWorkflowCommand): Promise { const persistedWorkflow = await this.fetchWorkflow(command); - let transientWorkflow = this.patchWorkflowFields(persistedWorkflow, command); - - transientWorkflow = await this.postProcessWorkflowUpdate.execute({ - workflow: transientWorkflow, - user: command.user, - }); + const transientWorkflow = this.patchWorkflowFields(persistedWorkflow, command); await this.persistWorkflow(transientWorkflow, command.user); return await this.getWorkflowUseCase.execute({ @@ -52,6 +45,10 @@ export class PatchWorkflowUsecase { transientWorkflow.tags = command.tags; } + if (command.active !== undefined && command.active !== null) { + transientWorkflow.status = command.active ? WorkflowStatusEnum.ACTIVE : WorkflowStatusEnum.INACTIVE; + } + return transientWorkflow; } diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts index 3f855934e7f..05fdb87e144 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts @@ -1,5 +1,2 @@ export * from './upsert-workflow.command'; export * from './upsert-workflow.usecase'; -export * from './upsert-workflow-data.command'; -export * from './upsert-step-data.command'; -export * from './preferences-request-upsert-data.command'; diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts deleted file mode 100644 index bd953292581..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ChannelTypeEnum } from '@novu/shared'; -import { IsBoolean, IsObject, IsOptional, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class ChannelPreferenceData { - @IsBoolean() - enabled: boolean; -} - -export class WorkflowPreferenceData { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - readOnly: boolean; -} - -export class WorkflowPreferencesUpsertData { - @ValidateNested() - all: WorkflowPreferenceData; - - @IsObject() - @ValidateNested({ each: true }) - channels: Record; -} - -export class PreferencesRequestUpsertDataCommand { - @IsOptional() - @ValidateNested() - @Type(() => WorkflowPreferencesUpsertData) - user: WorkflowPreferencesUpsertData | null; - - @IsOptional() - @ValidateNested() - @Type(() => WorkflowPreferencesUpsertData) - workflow?: WorkflowPreferencesUpsertData | null; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts deleted file mode 100644 index 8b55e29e18b..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsEnum, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator'; -import { MAX_NAME_LENGTH } from '@novu/application-generic'; -import { StepTypeEnum } from '@novu/shared'; - -export class UpsertStepDataCommand { - @IsString() - @IsNotEmpty() - @Length(1, MAX_NAME_LENGTH) - name: string; - - @IsEnum(StepTypeEnum) - type: StepTypeEnum; - - @IsOptional() - controlValues?: Record | null; - - @IsOptional() - @IsString() - @IsNotEmpty() - _id?: string; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts deleted file mode 100644 index 5807e413151..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - ArrayMaxSize, - IsArray, - IsBoolean, - IsEnum, - IsNotEmpty, - IsOptional, - IsString, - Length, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { MAX_NAME_LENGTH } from '@novu/application-generic'; -import { WorkflowCreationSourceEnum } from '@novu/shared'; -import { UpsertStepDataCommand } from './upsert-step-data.command'; -import { PreferencesRequestUpsertDataCommand } from './preferences-request-upsert-data.command'; - -export class UpsertWorkflowDataCommand { - @IsString() - @IsOptional() - workflowId?: string; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpsertStepDataCommand) - steps: UpsertStepDataCommand[]; - - @IsOptional() - @ValidateNested() - @Type(() => PreferencesRequestUpsertDataCommand) - preferences?: PreferencesRequestUpsertDataCommand; - - @IsString() - @IsNotEmpty() - @Length(1, MAX_NAME_LENGTH) - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - @ArrayMaxSize(16, { message: 'tags must contain no more than 16 elements' }) - tags?: string[]; - - @IsOptional() - @IsBoolean() - active?: boolean; - - @IsOptional() - @IsEnum(WorkflowCreationSourceEnum) - __source?: WorkflowCreationSourceEnum; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts index d0e9e19573e..2ddf864f802 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts @@ -1,7 +1,110 @@ -import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; -import { IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsOptional, + IsString, + ValidateNested, + ArrayMaxSize, + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + Length, + IsObject, +} from 'class-validator'; import { Type } from 'class-transformer'; -import { UpsertWorkflowDataCommand } from './upsert-workflow-data.command'; + +import { EnvironmentWithUserObjectCommand, MAX_NAME_LENGTH } from '@novu/application-generic'; +import { StepTypeEnum, WorkflowCreationSourceEnum, ChannelTypeEnum } from '@novu/shared'; + +export class ChannelPreferenceData { + @IsBoolean() + enabled: boolean; +} + +export class WorkflowPreferenceData { + @IsBoolean() + enabled: boolean; + + @IsBoolean() + readOnly: boolean; +} + +export class WorkflowPreferencesUpsertData { + @ValidateNested() + all: WorkflowPreferenceData; + + @IsObject() + @ValidateNested({ each: true }) + channels: Record; +} + +export class PreferencesRequestUpsertDataCommand { + @IsOptional() + @ValidateNested() + @Type(() => WorkflowPreferencesUpsertData) + user: WorkflowPreferencesUpsertData | null; + + @IsOptional() + @ValidateNested() + @Type(() => WorkflowPreferencesUpsertData) + workflow?: WorkflowPreferencesUpsertData | null; +} + +export class UpsertStepDataCommand { + @IsString() + @IsNotEmpty() + @Length(1, MAX_NAME_LENGTH) + name: string; + + @IsEnum(StepTypeEnum) + type: StepTypeEnum; + + @IsOptional() + controlValues?: Record | null; + + @IsOptional() + @IsString() + @IsNotEmpty() + _id?: string; +} + +export class UpsertWorkflowDataCommand { + @IsString() + @IsOptional() + workflowId?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpsertStepDataCommand) + steps: UpsertStepDataCommand[]; + + @IsOptional() + @ValidateNested() + @Type(() => PreferencesRequestUpsertDataCommand) + preferences?: PreferencesRequestUpsertDataCommand; + + @IsString() + @IsNotEmpty() + @Length(1, MAX_NAME_LENGTH) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(16, { message: 'tags must contain no more than 16 elements' }) + tags?: string[]; + + @IsOptional() + @IsBoolean() + active?: boolean; + + @IsOptional() + @IsEnum(WorkflowCreationSourceEnum) + __source?: WorkflowCreationSourceEnum; +} export class UpsertWorkflowCommand extends EnvironmentWithUserObjectCommand { @IsOptional() diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index adf24b7c0a7..6b3bbfa9351 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -1,17 +1,36 @@ -import { NotificationGroupRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import Ajv, { ErrorObject } from 'ajv'; +import addFormats from 'ajv-formats'; +import _ from 'lodash'; + import { + ControlValuesRepository, + NotificationGroupRepository, + NotificationStepEntity, + NotificationTemplateEntity, +} from '@novu/dal'; +import { + ContentIssue, CreateWorkflowDto, + JSONSchemaDto, DEFAULT_WORKFLOW_PREFERENCES, slugify, + StepContentIssueEnum, StepCreateDto, - StepDto, + StepIssuesDto, StepUpdateDto, UpdateWorkflowDto, UserSessionData, + StepTypeEnum, WorkflowCreationSourceEnum, WorkflowOriginEnum, WorkflowResponseDto, WorkflowTypeEnum, + ControlValuesLevelEnum, + WorkflowStatusEnum, + StepIssues, + ControlSchemas, + DigestUnitEnum, } from '@novu/shared'; import { CreateWorkflow as CreateWorkflowGeneric, @@ -25,14 +44,19 @@ import { UpdateWorkflow as UpdateWorkflowGeneric, UpdateWorkflowCommand, WorkflowInternalResponseDto, + TierRestrictionsValidateUsecase, + UpsertControlValuesCommand, + DeleteControlValuesCommand, + UpsertControlValuesUseCase, + DeleteControlValuesUseCase, + TierRestrictionsValidateCommand, } from '@novu/application-generic'; -import { BadRequestException, Injectable } from '@nestjs/common'; -import { UpsertWorkflowCommand } from './upsert-workflow.command'; + +import { UpsertWorkflowCommand, UpsertWorkflowDataCommand } from './upsert-workflow.command'; import { stepTypeToControlSchema } from '../../shared'; -import { PatchStepUsecase } from '../patch-step-data'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; import { GetWorkflowCommand, GetWorkflowUseCase } from '../get-workflow'; -import { UpsertWorkflowDataCommand } from './upsert-workflow-data.command'; +import { buildVariables } from '../../util/build-variables'; +import { BuildAvailableVariableSchemaCommand, BuildAvailableVariableSchemaUsecase } from '../build-variable-schema'; @Injectable() export class UpsertWorkflowUseCase { @@ -40,26 +64,23 @@ export class UpsertWorkflowUseCase { private createWorkflowGenericUsecase: CreateWorkflowGeneric, private updateWorkflowGenericUsecase: UpdateWorkflowGeneric, private notificationGroupRepository: NotificationGroupRepository, - private workflowUpdatePostProcess: PostProcessWorkflowUpdate, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private getWorkflowUseCase: GetWorkflowUseCase, - private patchStepDataUsecase: PatchStepUsecase + private buildAvailableVariableSchemaUsecase: BuildAvailableVariableSchemaUsecase, + private controlValuesRepository: ControlValuesRepository, + private upsertControlValuesUseCase: UpsertControlValuesUseCase, + private deleteControlValuesUseCase: DeleteControlValuesUseCase, + private tierRestrictionsValidateUsecase: TierRestrictionsValidateUsecase ) {} @InstrumentUsecase() async execute(command: UpsertWorkflowCommand): Promise { const workflowForUpdate = await this.queryWorkflow(command); - let persistedWorkflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); - persistedWorkflow = await this.upsertControlValues(persistedWorkflow, command); - const validatedWorkflowWithIssues = await this.workflowUpdatePostProcess.execute({ - user: command.user, - workflow: persistedWorkflow, - }); - await this.persistWorkflow(validatedWorkflowWithIssues); - + const persistedWorkflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); + await this.upsertControlValues(persistedWorkflow, command); const workflow = await this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ - workflowIdOrInternalId: validatedWorkflowWithIssues._id, + workflowIdOrInternalId: persistedWorkflow._id, user: command.user, }) ); @@ -67,32 +88,6 @@ export class UpsertWorkflowUseCase { return workflow; } - @Instrument() - private async getWorkflow(workflowId: string, command: UpsertWorkflowCommand): Promise { - return await this.getWorkflowByIdsUseCase.execute( - GetWorkflowByIdsCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - workflowIdOrInternalId: workflowId, - }) - ); - } - - @Instrument() - private async persistWorkflow(workflowWithIssues: WorkflowInternalResponseDto) { - const command = UpdateWorkflowCommand.create({ - id: workflowWithIssues._id, - environmentId: workflowWithIssues._environmentId, - organizationId: workflowWithIssues._organizationId, - userId: workflowWithIssues._creatorId, - type: workflowWithIssues.type!, - ...workflowWithIssues, - }); - - await this.updateWorkflowGenericUsecase.execute(command); - } - @Instrument() private async queryWorkflow(command: UpsertWorkflowCommand): Promise { if (!command.workflowIdOrInternalId) { @@ -117,7 +112,7 @@ export class UpsertWorkflowUseCase { if (existingWorkflow && isWorkflowUpdateDto(command.workflowDto, command.workflowIdOrInternalId)) { return await this.updateWorkflowGenericUsecase.execute( UpdateWorkflowCommand.create( - this.convertCreateToUpdateCommand(command.workflowDto, command.user, existingWorkflow) + await this.convertCreateToUpdateCommand(command.workflowDto, command.user, existingWorkflow) ) ); } @@ -138,6 +133,7 @@ export class UpsertWorkflowUseCase { if (!notificationGroupId) { throw new BadRequestException('Notification group not found'); } + const steps = await this.mapSteps(command.user, workflowDto.steps); return { notificationGroupId, @@ -148,28 +144,48 @@ export class UpsertWorkflowUseCase { __source: workflowDto.__source || WorkflowCreationSourceEnum.DASHBOARD, type: WorkflowTypeEnum.BRIDGE, origin: WorkflowOriginEnum.NOVU_CLOUD, - steps: this.mapSteps(workflowDto.steps), + steps, active: isWorkflowActive, description: workflowDto.description || '', tags: workflowDto.tags || [], userPreferences: command.workflowDto.preferences?.user ?? null, defaultPreferences: command.workflowDto.preferences?.workflow ?? DEFAULT_WORKFLOW_PREFERENCES, triggerIdentifier: slugify(workflowDto.name), + status: this.computeStatus(command.workflowDto, steps), }; } - private convertCreateToUpdateCommand( + private computeStatus(workflow: UpsertWorkflowDataCommand, steps: NotificationStep[]) { + if (workflow.active === false) { + return WorkflowStatusEnum.INACTIVE; + } + + const hasIssues = steps.filter((step) => this.hasControlIssues(step.issues)).length > 0; + if (!hasIssues) { + return WorkflowStatusEnum.ACTIVE; + } + + return WorkflowStatusEnum.ERROR; + } + + private hasControlIssues(issue: StepIssues | undefined) { + return issue?.controls && Object.keys(issue.controls).length > 0; + } + + private async convertCreateToUpdateCommand( workflowDto: UpdateWorkflowDto, user: UserSessionData, existingWorkflow: NotificationTemplateEntity - ): UpdateWorkflowCommand { + ): Promise { + const steps = await this.mapSteps(user, workflowDto.steps, existingWorkflow); + return { id: existingWorkflow._id, environmentId: existingWorkflow._environmentId, organizationId: user.organizationId, userId: user._id, name: workflowDto.name, - steps: this.mapSteps(workflowDto.steps, existingWorkflow), + steps, rawData: workflowDto, type: WorkflowTypeEnum.BRIDGE, description: workflowDto.description, @@ -177,16 +193,18 @@ export class UpsertWorkflowUseCase { defaultPreferences: workflowDto.preferences?.workflow ?? DEFAULT_WORKFLOW_PREFERENCES, tags: workflowDto.tags, active: workflowDto.active ?? true, + status: this.computeStatus(workflowDto, steps), }; } - private mapSteps( + private async mapSteps( + user: UserSessionData, commandWorkflowSteps: Array, persistedWorkflow?: NotificationTemplateEntity | undefined - ): NotificationStep[] { + ): Promise { const steps: NotificationStep[] = []; for (const step of commandWorkflowSteps) { - const mappedStep = this.mapSingleStep(persistedWorkflow, step); + const mappedStep = await this.mapSingleStep(user, persistedWorkflow, step); const baseStepId = mappedStep.stepId; if (baseStepId) { @@ -223,12 +241,33 @@ export class UpsertWorkflowUseCase { return currentStepId; } - private mapSingleStep( + private async mapSingleStep( + user: UserSessionData, persistedWorkflow: NotificationTemplateEntity | undefined, step: StepUpdateDto | StepCreateDto - ): NotificationStep { + ): Promise { const foundPersistedStep = this.getPersistedStepIfFound(persistedWorkflow, step); - const stepEntityToReturn = this.buildBaseStepEntity(step, foundPersistedStep); + const controlSchemas: ControlSchemas = foundPersistedStep?.template?.controls || stepTypeToControlSchema[step.type]; + const issues: StepIssuesDto = await this.buildIssues( + user, + foundPersistedStep, + persistedWorkflow, + step, + controlSchemas + ); + + const stepEntityToReturn = { + template: { + type: step.type, + name: step.name, + controls: controlSchemas, + content: '', + }, + stepId: foundPersistedStep?.stepId || slugify(step.name), + name: step.name, + issues, + }; + if (foundPersistedStep) { return { ...stepEntityToReturn, @@ -241,20 +280,44 @@ export class UpsertWorkflowUseCase { return stepEntityToReturn; } - private buildBaseStepEntity( - step: StepDto | StepUpdateDto, - foundPersistedStep?: NotificationStepEntity - ): NotificationStep { - return { - template: { - type: step.type, - name: step.name, - controls: foundPersistedStep?.template?.controls || stepTypeToControlSchema[step.type], - content: '', - }, - stepId: foundPersistedStep?.stepId || slugify(step.name), - name: step.name, - }; + private async buildIssues( + user: UserSessionData, + foundPersistedStep: NotificationStepEntity | undefined, + persistedWorkflow: NotificationTemplateEntity | undefined, + step: StepCreateDto | StepUpdateDto, + controlSchemas: ControlSchemas + ) { + const variableSchema = await this.buildAvailableVariableSchemaUsecase.execute( + BuildAvailableVariableSchemaCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + userId: user._id, + stepInternalId: foundPersistedStep?.stepId, + workflow: persistedWorkflow, + ...(step.controlValues ? { optimisticControlValues: step.controlValues } : {}), + }) + ); + + let controlValueLocal = step.controlValues; + + if (!controlValueLocal) { + controlValueLocal = ( + await this.controlValuesRepository.findOne({ + _environmentId: user.environmentId, + _organizationId: user.organizationId, + _workflowId: persistedWorkflow?._id, + _stepId: foundPersistedStep?._templateId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }) + )?.controls; + } + + const controlIssues = processControlValuesBySchema(controlSchemas?.schema, controlValueLocal || {}); + const liquidTemplateIssues = processControlValuesByLiquid(variableSchema, controlValueLocal || {}); + const customIssues = await this.processCustomControlValues(user, step.type, controlValueLocal || {}); + const customControlIssues = _.isEmpty(customIssues) ? {} : { controls: customIssues }; + + return _.merge(controlIssues, liquidTemplateIssues, customControlIssues); } private getPersistedStepIfFound( @@ -288,29 +351,60 @@ export class UpsertWorkflowUseCase { )?._id; } - /** - * @deprecated This method will be removed in future versions. - * Please use `the patch step data instead, do not add here anything` instead. - */ @Instrument() private async upsertControlValues( workflow: NotificationTemplateEntity, command: UpsertWorkflowCommand - ): Promise { - for (const step of workflow.steps) { - const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); - if (controlValues === undefined) { - continue; - } - await this.patchStepDataUsecase.execute({ - controlValues, - workflowIdOrInternalId: workflow._id, - stepIdOrInternalId: step._templateId, - user: command.user, - }); + ): Promise { + const controlValuesUpdates = this.getControlValuesUpdates(workflow.steps, command); + if (controlValuesUpdates.length === 0) return; + + await Promise.all( + controlValuesUpdates.map((update) => this.executeControlValuesUpdate(update, workflow._id, command)) + ); + } + + private getControlValuesUpdates(steps: NotificationStepEntity[], command: UpsertWorkflowCommand) { + return steps + .map((step) => { + const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); + if (controlValues === undefined) return null; + + return { + step, + controlValues, + shouldDelete: controlValues === null, + }; + }) + .filter((update): update is NonNullable => update !== null); + } + + private executeControlValuesUpdate( + update: { step: NotificationStepEntity; controlValues: Record | null; shouldDelete: boolean }, + workflowId: string, + command: UpsertWorkflowCommand + ) { + if (update.shouldDelete) { + return this.deleteControlValuesUseCase.execute( + DeleteControlValuesCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + stepId: update.step._templateId, + workflowId, + userId: command.user._id, + }) + ); } - return await this.getWorkflow(workflow._id, command); + return this.upsertControlValuesUseCase.execute( + UpsertControlValuesCommand.create({ + organizationId: command.user.organizationId, + environmentId: command.user.environmentId, + notificationStepEntity: update.step, + workflowId, + newControlValues: update.controlValues || {}, + }) + ); } private findControlValueInRequest( @@ -325,12 +419,148 @@ export class UpsertWorkflowUseCase { return stepRequest.name === step.name; })?.controlValues; } + + private async processCustomControlValues( + user: UserSessionData, + stepType: StepTypeEnum, + controlValues: Record | null + ): Promise { + const cleanedControlValues = controlValues ? cleanObject(controlValues) : {}; + + const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute( + TierRestrictionsValidateCommand.create({ + amount: cleanedControlValues.amount as string | undefined, + unit: cleanedControlValues.unit as string | undefined, + cron: cleanedControlValues.cron as string | undefined, + organizationId: user.organizationId, + stepType, + }) + ); + + if (!restrictionsErrors) { + return {}; + } + + const result: Record = {}; + for (const restrictionsError of restrictionsErrors) { + result[restrictionsError.controlKey] = [ + { + issueType: StepContentIssueEnum.TIER_LIMIT_EXCEEDED, + message: restrictionsError.message, + }, + ]; + } + + return result; + } } function isWorkflowUpdateDto(workflowDto: UpsertWorkflowDataCommand, id?: string): workflowDto is UpdateWorkflowDto { return !!id; } -const isUniqueStepId = (stepIdToValidate: string, otherStepsIds: string[]) => { +function isUniqueStepId(stepIdToValidate: string, otherStepsIds: string[]) { return !otherStepsIds.some((stepId) => stepId === stepIdToValidate); -}; +} + +function processControlValuesByLiquid( + variableSchema: JSONSchemaDto | undefined, + controlValues: Record | null +): StepIssuesDto { + const issues: StepIssuesDto = {}; + + for (const [controlKey, controlValue] of Object.entries(controlValues || {})) { + const liquidTemplateIssues = buildVariables(variableSchema, controlValue); + + if (liquidTemplateIssues.invalidVariables.length > 0) { + issues.controls = issues.controls || {}; + + issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => ({ + message: `${error.message}, variable: ${error.output}`, + issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, + variableName: error.output, + })); + } + } + + return issues; +} + +function processControlValuesBySchema( + controlSchema: JSONSchemaDto | undefined, + controlValues: Record | null +): StepIssuesDto { + let issues: StepIssuesDto = {}; + + const cleanedControlValues = controlValues ? cleanObject(controlValues) : {}; + + if (!controlSchema || !cleanedControlValues) { + return issues; + } + + const ajv = new Ajv({ allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(controlSchema); + const isValid = validate(cleanedControlValues); + const errors = validate.errors as null | ErrorObject[]; + + if (!isValid && errors && errors?.length !== 0 && cleanedControlValues) { + issues = { + controls: errors.reduce( + (acc, error) => { + const path = getErrorPath(error); + if (!acc[path]) { + acc[path] = []; + } + + acc[path].push({ + message: error.message || 'Invalid value', + issueType: mapAjvErrorToIssueType(error), + variableName: path, + }); + + return acc; + }, + {} as Record + ), + }; + + return issues; + } + + return issues; +} + +/* + * Extracts the path from the error object: + * 1. If instancePath exists, removes leading slash and converts remaining slashes to dots + * 2. If no instancePath, uses missingProperty from error params + * Example: "/foo/bar" becomes "foo.bar" + */ +function getErrorPath(error: ErrorObject): string { + return (error.instancePath.substring(1) || error.params.missingProperty)?.replace(/\//g, '.'); +} + +function cleanObject( + obj: Record, + valuesToClean: Array = ['', null, undefined] +) { + if (typeof obj !== 'object' || obj === null) return obj; + + return Object.fromEntries( + Object.entries(obj) + .filter(([unused, value]) => !valuesToClean.includes(value as string | null | undefined)) + .map(([key, value]) => [key, cleanObject(value, valuesToClean)]) + ); +} + +function mapAjvErrorToIssueType(error: ErrorObject): StepContentIssueEnum { + switch (error.keyword) { + case 'required': + return StepContentIssueEnum.MISSING_VALUE; + case 'type': + return StepContentIssueEnum.MISSING_VALUE; + default: + return StepContentIssueEnum.MISSING_VALUE; + } +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts index a839d164b56..5834d99832a 100644 --- a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts @@ -10,7 +10,12 @@ import { StepTypeEnum, UserSessionData, } from '@novu/shared'; -import { Instrument, InstrumentUsecase, TierRestrictionsValidateUsecase } from '@novu/application-generic'; +import { + Instrument, + InstrumentUsecase, + TierRestrictionsValidateCommand, + TierRestrictionsValidateUsecase, +} from '@novu/application-generic'; import { PrepareAndValidateContentCommand } from './prepare-and-validate-content.command'; import { flattenJson, flattenToNested, mergeObjects } from '../../../util/jsonUtils'; @@ -387,16 +392,14 @@ export class PrepareAndValidateContentUsecase { user: UserSessionData, stepType?: StepTypeEnum ): Promise> { - const deferDuration = - isValidDigestUnit(defaultControlValues.unit) && isNumber(defaultControlValues.amount) - ? calculateMilliseconds(defaultControlValues.amount, defaultControlValues.unit) - : 0; - - const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute({ - deferDurationMs: deferDuration, - organizationId: user.organizationId, - stepType, - }); + const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute( + TierRestrictionsValidateCommand.create({ + amount: defaultControlValues.amount as string | undefined, + unit: defaultControlValues.unit as string | undefined, + organizationId: user.organizationId, + stepType, + }) + ); if (!restrictionsErrors) { return {}; @@ -421,30 +424,3 @@ export class PrepareAndValidateContentUsecase { return result; } } - -function calculateMilliseconds(amount: number, unit: DigestUnitEnum): number { - switch (unit) { - case DigestUnitEnum.SECONDS: - return amount * 1000; - case DigestUnitEnum.MINUTES: - return amount * 1000 * 60; - case DigestUnitEnum.HOURS: - return amount * 1000 * 60 * 60; - case DigestUnitEnum.DAYS: - return amount * 1000 * 60 * 60 * 24; - case DigestUnitEnum.WEEKS: - return amount * 1000 * 60 * 60 * 24 * 7; - case DigestUnitEnum.MONTHS: - return amount * 1000 * 60 * 60 * 24 * 30; // Using 30 days as an approximation for a month - default: - return 0; - } -} - -function isValidDigestUnit(unit: unknown): unit is DigestUnitEnum { - return Object.values(DigestUnitEnum).includes(unit as DigestUnitEnum); -} - -function isNumber(value: unknown): value is number { - return !Number.isNaN(Number(value)); -} diff --git a/apps/api/src/app/workflows-v2/util/build-variables.ts b/apps/api/src/app/workflows-v2/util/build-variables.ts new file mode 100644 index 00000000000..0cb1764c202 --- /dev/null +++ b/apps/api/src/app/workflows-v2/util/build-variables.ts @@ -0,0 +1,93 @@ +import _ from 'lodash'; + +import { PinoLogger } from '@novu/application-generic'; + +import { Variable, extractLiquidTemplateVariables, TemplateVariables } from './template-parser/liquid-parser'; +import { transformMailyContentToLiquid } from '../usecases/generate-preview/transform-maily-content-to-liquid'; +import { isStringTipTapNode } from './tip-tap.util'; + +export function buildVariables( + variableSchema: Record | undefined, + controlValue: unknown | Record, + logger?: PinoLogger +): TemplateVariables { + let variableControlValue = controlValue; + + if (isStringTipTapNode(variableControlValue)) { + try { + variableControlValue = transformMailyContentToLiquid(JSON.parse(variableControlValue)); + } catch (error) { + logger?.error( + { + err: error as Error, + controlKey: 'unknown', + message: 'Failed to transform maily content to liquid syntax', + }, + 'BuildVariables' + ); + } + } + + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue)); + + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( + variableSchema || {}, + validVariables + ); + + return { + validVariables: validSchemaVariables, + invalidVariables: [...invalidVariables, ...invalidSchemaVariables], + }; +} + +/** + * Validates variables against a schema to identify which ones are valid/invalid. + * Returns an object containing arrays of valid and invalid variables. + */ +function identifyUnknownVariables( + variableSchema: Record, + validVariables: Variable[] +): TemplateVariables { + const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); + + const result = validVariablesCopy.reduce( + (acc, variable: Variable) => { + const parts = variable.name.split('.'); + let isValid = true; + let currentPath = 'properties'; + + for (const part of parts) { + currentPath += `.${part}`; + const valueSearch = _.get(variableSchema, currentPath); + + currentPath += '.properties'; + const propertiesSearch = _.get(variableSchema, currentPath); + + if (valueSearch === undefined && propertiesSearch === undefined) { + isValid = false; + break; + } + } + + if (isValid) { + acc.validVariables.push(variable); + } else { + acc.invalidVariables.push({ + name: variable.output, + context: variable.context, + message: 'Variable is not supported', + output: variable.output, + }); + } + + return acc; + }, + { + validVariables: [] as Variable[], + invalidVariables: [] as Variable[], + } as TemplateVariables + ); + + return result; +} diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index 8cba93edd84..d5eec147d0d 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -9,13 +9,23 @@ const LIQUID_CONFIG = { } as const; export type Variable = { + /** + * The variable name/path (e.g. for valid variables "user.name", + * for invalid variables will fallback to output "{{user.name | upcase}}") + */ + name: string; + + /** The surrounding context where the variable was found, useful for error messages */ context?: string; + + /** Error message if the variable is invalid */ message?: string; - name: string; - template: string; + + /** The full liquid output string (e.g. "{{user.name | upcase}}") */ + output: string; }; -export type TemplateParseResult = { +export type TemplateVariables = { validVariables: Variable[]; invalidVariables: Variable[]; }; @@ -62,7 +72,7 @@ function isLiquidErrors(error: unknown): error is LiquidErrors { * @param template - The Liquid template string to parse * @returns Object containing arrays of valid and invalid variables found in the template */ -export function extractLiquidTemplateVariables(template: string): TemplateParseResult { +export function extractLiquidTemplateVariables(template: string): TemplateVariables { if (!isValidTemplate(template)) { return { validVariables: [], invalidVariables: [] }; } @@ -75,7 +85,7 @@ export function extractLiquidTemplateVariables(template: string): TemplateParseR return processLiquidRawOutput(liquidRawOutput); } -function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { +function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables { const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; const processedVariables = new Set(); @@ -100,7 +110,7 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { name: rawOutput, message: e.message, context: e.context, - template: rawOutput, + output: rawOutput, }, false ); @@ -112,7 +122,7 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { return { validVariables, invalidVariables }; } -function parseByLiquid(rawOutput: string): TemplateParseResult { +function parseByLiquid(rawOutput: string): TemplateVariables { const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; const engine = new Liquid(LIQUID_CONFIG); @@ -123,11 +133,11 @@ function parseByLiquid(rawOutput: string): TemplateParseResult { const result = extractProps(template); if (result.valid && result.props.length > 0) { - validVariables.push({ name: result.props.join('.'), template: rawOutput }); + validVariables.push({ name: result.props.join('.'), output: rawOutput }); } if (!result.valid) { - invalidVariables.push({ name: template?.token?.input, message: result.error, template: rawOutput }); + invalidVariables.push({ name: template?.token?.input, message: result.error, output: rawOutput }); } } }); diff --git a/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts b/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts index 36c48ba7e0f..68b1016e1f1 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts @@ -14,7 +14,7 @@ export function isValidTemplate(template: unknown): template is string { export function extractLiquidExpressions(str: string): string[] { if (!str) return []; - const LIQUID_EXPRESSION_PATTERN = /{{\s*[^{}]+}}/g; + const LIQUID_EXPRESSION_PATTERN = /{{\s*[^{}]*}}/g; return str.match(LIQUID_EXPRESSION_PATTERN) || []; } diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts index 2c55a9dc5d0..72473fa3baf 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -15,7 +15,6 @@ import { slugify, StepContentIssueEnum, StepCreateDto, - StepDataDto, StepTypeEnum, StepUpdateDto, UpdateStepBody, @@ -220,8 +219,8 @@ describe('Workflow Controller E2E API Testing', () => { } const updatedWorkflow = novuRestResult.value; const firstStep = updatedWorkflow.steps[0]; - expect(firstStep.issues?.body, JSON.stringify(firstStep)).to.be.empty; - expect(firstStep.issues?.controls, JSON.stringify(firstStep.issues)).to.be.empty; + expect(firstStep.issues, JSON.stringify(firstStep)).to.be.empty; + expect(firstStep.issues, JSON.stringify(firstStep.issues)).to.be.empty; }); }); @@ -253,15 +252,12 @@ describe('Workflow Controller E2E API Testing', () => { }); it('should show digest control value issues when illegal value provided', async () => { - const steps = [{ ...buildDigestStep() }]; + const steps = [{ ...buildDigestStep({ controlValues: { amount: '555', unit: 'days' } }) }]; const workflowCreated = await createWorkflowAndReturn({ steps }); - const values = { controlValues: { amount: '555', unit: 'days' } }; - const updatedStep = await patchStepRest(workflowCreated._id, workflowCreated.steps[0]._id, values); + const step = workflowCreated.steps[0]; - expect(updatedStep.issues?.controls?.amount[0].issueType).to.deep.equal( - StepContentIssueEnum.TIER_LIMIT_EXCEEDED - ); - expect(updatedStep.issues?.controls?.unit[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); + expect(step.issues?.controls?.amount[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); + expect(step.issues?.controls?.unit[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); }); }); }); @@ -839,7 +835,7 @@ describe('Workflow Controller E2E API Testing', () => { updatedWorkflow = await patchWorkflowAndReturnResponse(workflowDto._id, false); expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.INACTIVE); updatedWorkflow = await patchWorkflowAndReturnResponse(workflowDto._id, true); - expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.ERROR); + expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.ACTIVE); }); }); @@ -1114,6 +1110,7 @@ describe('Workflow Controller E2E API Testing', () => { { name: 'Email Test Step', type: StepTypeEnum.EMAIL, + controlValues: { body: 'Welcome {{}}' }, }, ], }); @@ -1122,9 +1119,6 @@ describe('Workflow Controller E2E API Testing', () => { expect(res.isSuccessResult()).to.be.true; if (res.isSuccessResult()) { const workflow = res.value; - await workflowsClient.patchWorkflowStepData(workflow._id, workflow.steps[0]._id, { - controlValues: { body: 'Welcome {{}}' }, - }); const stepData = await getStepData(workflow._id, workflow.steps[0]._id); expect(stepData.issues, 'Step data should have issues').to.exist; @@ -1134,12 +1128,18 @@ describe('Workflow Controller E2E API Testing', () => { } }); - it('should show issues for invalid URLs', async () => { + // todo add validation for invalid URLs + it.skip('should show issues for invalid URLs', async () => { const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('test-issues', { steps: [ { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, + controlValues: { + redirect: { url: 'not-good-url-please-replace' }, + primaryAction: { redirect: { url: 'not-good-url-please-replace' } }, + secondaryAction: { redirect: { url: 'not-good-url-please-replace' } }, + }, }, ], }); @@ -1148,13 +1148,6 @@ describe('Workflow Controller E2E API Testing', () => { expect(res.isSuccessResult()).to.be.true; if (res.isSuccessResult()) { const workflow = res.value; - await workflowsClient.patchWorkflowStepData(workflow._id, workflow.steps[0]._id, { - controlValues: { - redirect: { url: 'not-good-url-please-replace' }, - primaryAction: { redirect: { url: 'not-good-url-please-replace' } }, - secondaryAction: { redirect: { url: 'not-good-url-please-replace' } }, - }, - }); const stepData = await getStepData(workflow._id, workflow.steps[0]._id); expect(stepData.issues, 'Step data should have issues').to.exist; diff --git a/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts b/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts index 62b0e07fc9b..a29a6bdf30b 100644 --- a/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts @@ -173,9 +173,9 @@ export class AddJob { organizationId: command.organizationId, }) ); - - if (errors) { - Logger.warn({ errors, jobId: job._id }, 'Defer duration limit exceeded for job', LOG_CONTEXT); + if (errors.length > 0) { + const errorMessages = errors?.map((error) => error.message).join(', '); + Logger.warn({ errors, jobId: job._id }, errorMessages, LOG_CONTEXT); await this.executionLogRoute.execute( ExecutionLogRouteCommand.create({ @@ -185,6 +185,7 @@ export class AddJob { status: ExecutionDetailsStatusEnum.FAILED, isTest: false, isRetry: false, + raw: JSON.stringify({ errors: errorMessages }), }) ); diff --git a/libs/application-generic/package.json b/libs/application-generic/package.json index e25a2df3866..185854a1525 100644 --- a/libs/application-generic/package.json +++ b/libs/application-generic/package.json @@ -83,6 +83,7 @@ "launchdarkly-node-server-sdk": "^7.0.1", "lodash": "^4.17.15", "mixpanel": "^0.17.0", + "cron-parser": "^4.9.0", "nanoid": "^3.1.20", "nestjs-otel": "6.1.1", "nestjs-pino": "4.1.0", diff --git a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.command.ts b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.command.ts index edc5ed496cd..bc540e80d59 100644 --- a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.command.ts +++ b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.command.ts @@ -1,13 +1,31 @@ -import { IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { + IsEnum, + IsNumber, + IsOptional, + isString, + IsString, +} from 'class-validator'; -import { StepTypeEnum } from '@novu/shared'; +import { DigestUnitEnum, StepTypeEnum } from '@novu/shared'; import { OrganizationLevelCommand } from '../../commands'; export class TierRestrictionsValidateCommand extends OrganizationLevelCommand { + @IsString() + @IsOptional() + amount?: string; + + @IsString() + @IsOptional() + unit?: string; + @IsNumber() @IsOptional() deferDurationMs?: number; + @IsOptional() + @IsString() + cron?: string; + @IsEnum(StepTypeEnum) @IsOptional() stepType?: StepTypeEnum; diff --git a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.consts.ts b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.consts.ts deleted file mode 100644 index 33bee08b8b3..00000000000 --- a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.consts.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; -export const FREE_TIER_MAX_DELAY_DAYS = 30; -export const BUSINESS_TIER_MAX_DELAY_DAYS = 90; -export const MAX_DELAY_FREE_TIER = - FREE_TIER_MAX_DELAY_DAYS * MILLISECONDS_IN_DAY; // 30 days in milliseconds -export const MAX_DELAY_BUSINESS_TIER = - BUSINESS_TIER_MAX_DELAY_DAYS * MILLISECONDS_IN_DAY; // 90 days in milliseconds diff --git a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.response.ts b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.response.ts index 53d7b9220a6..c3675843885 100644 --- a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.response.ts +++ b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.response.ts @@ -1,10 +1,12 @@ export enum ErrorEnum { TIER_LIMIT_EXCEEDED = 'TIER_LIMIT_EXCEEDED', + INVALID_DEFER_DURATION = 'INVALID_DEFER_DURATION', } -type TierValidationError = { +export type TierValidationError = { + controlKey: string; error: ErrorEnum; message: string; }; -export type TierRestrictionsValidateResponse = TierValidationError[] | null; +export type TierRestrictionsValidateResponse = TierValidationError[]; diff --git a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.usecase.ts b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.usecase.ts index 5d4ba0a931d..3aade636a7c 100644 --- a/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.usecase.ts +++ b/libs/application-generic/src/usecases/tier-restrictions-validate/tier-restrictions-validate.usecase.ts @@ -1,21 +1,31 @@ import { Injectable } from '@nestjs/common'; +import { parseExpression as parseCronExpression } from 'cron-parser'; -import { ApiServiceLevelEnum, StepTypeEnum } from '@novu/shared'; +import { + ApiServiceLevelEnum, + DigestUnitEnum, + StepTypeEnum, +} from '@novu/shared'; import { CommunityOrganizationRepository } from '@novu/dal'; +import { differenceInMilliseconds } from 'date-fns'; + import { TierRestrictionsValidateCommand } from './tier-restrictions-validate.command'; import { ErrorEnum, TierRestrictionsValidateResponse, + TierValidationError, } from './tier-restrictions-validate.response'; -import { - FREE_TIER_MAX_DELAY_DAYS, - BUSINESS_TIER_MAX_DELAY_DAYS, - MAX_DELAY_FREE_TIER, - MAX_DELAY_BUSINESS_TIER, -} from './tier-restrictions-validate.consts'; import { InstrumentUsecase } from '../../instrumentation'; +export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; +export const FREE_TIER_MAX_DELAY_DAYS = 30; +export const BUSINESS_TIER_MAX_DELAY_DAYS = 90; +export const MAX_DELAY_FREE_TIER = + FREE_TIER_MAX_DELAY_DAYS * MILLISECONDS_IN_DAY; // 30 days in milliseconds +export const MAX_DELAY_BUSINESS_TIER = + BUSINESS_TIER_MAX_DELAY_DAYS * MILLISECONDS_IN_DAY; // 90 days in milliseconds + @Injectable() export class TierRestrictionsValidateUsecase { constructor( @@ -26,57 +36,150 @@ export class TierRestrictionsValidateUsecase { async execute( command: TierRestrictionsValidateCommand, ): Promise { - return this.validateControlValuesByTierLimits( - command.organizationId, - command.deferDurationMs, - command.stepType, - ); - } + if (![StepTypeEnum.DIGEST, StepTypeEnum.DELAY].includes(command.stepType)) { + return []; + } - private async validateControlValuesByTierLimits( - organizationId: string, - deferDurationMs?: number, - stepType?: StepTypeEnum, - ): Promise { - const controlValueNeedTierValidation = - stepType === StepTypeEnum.DIGEST || stepType === StepTypeEnum.DELAY; + const apiServiceLevel = ( + await this.organizationRepository.findById(command.organizationId) + )?.apiServiceLevel; - if (!controlValueNeedTierValidation || !deferDurationMs) { - return null; - } + if (isCronExpression(command.cron)) { + // TODO: Implement cron expression validation - const issues: TierRestrictionsValidateResponse = []; - const organization = - await this.organizationRepository.findById(organizationId); - const tier = organization?.apiServiceLevel; - const isPaidTier = [ - tier === undefined || - tier === ApiServiceLevelEnum.BUSINESS || - tier === ApiServiceLevelEnum.ENTERPRISE, - ]; - - if (isPaidTier) { - if (deferDurationMs > MAX_DELAY_BUSINESS_TIER) { - issues.push({ - error: ErrorEnum.TIER_LIMIT_EXCEEDED, - message: - `The maximum delay allowed is ${BUSINESS_TIER_MAX_DELAY_DAYS} days. ` + - 'Please contact our support team to discuss extending this limit for your use case.', - }); - } + /* + * const deferDurationMs = this.buildCronDeltaDeferDuration(command); + * const issue = buildIssue( + * deferDurationMs, + * getMaxDelay(apiServiceLevel), + * ErrorEnum.TIER_LIMIT_EXCEEDED, + * 'cron', + * ); + */ + + return []; } - if (tier === ApiServiceLevelEnum.FREE) { - if (deferDurationMs > MAX_DELAY_FREE_TIER) { - issues.push({ - error: ErrorEnum.TIER_LIMIT_EXCEEDED, - message: - `The maximum delay allowed is ${FREE_TIER_MAX_DELAY_DAYS} days. ` + - 'Please upgrade your plan.', - }); - } + if (isRegularDeferAction(command)) { + const deferDurationMs = calculateDeferDuration(command); + + const amountIssue = buildIssue( + deferDurationMs, + getMaxDelay(apiServiceLevel), + ErrorEnum.TIER_LIMIT_EXCEEDED, + 'amount', + ); + const unitIssue = buildIssue( + deferDurationMs, + getMaxDelay(apiServiceLevel), + ErrorEnum.TIER_LIMIT_EXCEEDED, + 'unit', + ); + + return [amountIssue, unitIssue].filter(Boolean); } - return issues.length === 0 ? null : issues; + return []; + } + + private buildCronDeltaDeferDuration( + command: TierRestrictionsValidateCommand, + ): number | null { + const cronExpression = parseCronExpression(command.cron); + const firstTime = cronExpression.next().toDate(); + const secondTime = cronExpression.next().toDate(); + + return differenceInMilliseconds(firstTime, secondTime); + } +} + +function calculateDeferDuration( + command: TierRestrictionsValidateCommand, +): number | null { + if (command.deferDurationMs) { + return command.deferDurationMs; + } + + if (isValidDigestUnit(command.unit) && isNumber(command.amount)) { + return calculateMilliseconds(command.amount, command.unit); } + + return null; +} + +function isValidDigestUnit(unit: unknown): unit is DigestUnitEnum { + return Object.values(DigestUnitEnum).includes(unit as DigestUnitEnum); +} + +function isNumber(value: unknown): value is number { + return !Number.isNaN(Number(value)); +} + +function calculateMilliseconds(amount: number, unit: DigestUnitEnum): number { + switch (unit) { + case DigestUnitEnum.SECONDS: + return amount * 1000; + case DigestUnitEnum.MINUTES: + return amount * 1000 * 60; + case DigestUnitEnum.HOURS: + return amount * 1000 * 60 * 60; + case DigestUnitEnum.DAYS: + return amount * 1000 * 60 * 60 * 24; + case DigestUnitEnum.WEEKS: + return amount * 1000 * 60 * 60 * 24 * 7; + case DigestUnitEnum.MONTHS: + return amount * 1000 * 60 * 60 * 24 * 30; // Using 30 days as an approximation for a month + default: + return 0; + } +} + +/* + * Cron expression is another term for a timed digest + */ +const isCronExpression = (cron: string) => { + return !!cron; +}; + +const isRegularDeferAction = (command: TierRestrictionsValidateCommand) => { + return ( + !!command.amount && + isNumber(command.amount) && + !!command.unit && + isValidDigestUnit(command.unit) + ); +}; + +function getMaxDelay(tier: ApiServiceLevelEnum): number { + if ( + tier === ApiServiceLevelEnum.BUSINESS || + tier === ApiServiceLevelEnum.ENTERPRISE + ) { + return MAX_DELAY_BUSINESS_TIER; + } + + return MAX_DELAY_FREE_TIER; +} + +function buildIssue( + deferDurationMs: number, + maxDelayMs: number, + error: ErrorEnum, + controlKey: string, +): TierValidationError | null { + if (deferDurationMs > maxDelayMs) { + return { + controlKey, + error, + message: + `The maximum delay allowed is ${msToDays(maxDelayMs)} days. ` + + 'Please contact our support team to discuss extending this limit for your use case.', + }; + } + + return null; +} + +function msToDays(ms: number): number { + return Math.floor(ms / (1000 * 60 * 60 * 24)); } diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index a10c9bfa848..11f207996b1 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -12,4 +12,3 @@ export * from './subscriber'; export * from './variants'; export * from './deepmerge'; export * from './generate-id'; -export * from './sanitize-preview-control-values'; diff --git a/libs/application-generic/src/utils/sanitize-preview-control-values.ts b/libs/application-generic/src/utils/sanitize-preview-control-values.ts deleted file mode 100644 index 03d19c4e6fc..00000000000 --- a/libs/application-generic/src/utils/sanitize-preview-control-values.ts +++ /dev/null @@ -1,222 +0,0 @@ -const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ - type: 'doc', - content: [ - { - type: 'paragraph', - attrs: { textAlign: 'left' }, - content: [{ type: 'text', text: ' ' }], - }, - ], -}); -const WHITESPACE = ' '; - -type Redirect = { - url: string; - target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; -}; - -type Action = { - label?: string; - redirect?: Redirect; -}; - -type LookBackWindow = { - amount: number; - unit: string; -}; - -function sanitizeRedirect(redirect: Redirect) { - if (!redirect.url || !redirect.target) { - return undefined; - } - - return { - url: redirect.url || 'https://example.com', - target: redirect.target || '_self', - }; -} - -function sanitizeAction(action: Action) { - if (!action?.label) { - return undefined; - } - - return { - label: action.label, - redirect: sanitizeRedirect(action.redirect), - }; -} - -function sanitizeInApp(controlValues: Record) { - if (!controlValues) return controlValues; - - const normalized: Record = { - subject: controlValues.subject || null, - body: - (controlValues.body as string)?.length === 0 - ? WHITESPACE - : controlValues.body, - avatar: controlValues.avatar || null, - primaryAction: null, - secondaryAction: null, - redirect: null, - data: controlValues.data || null, - }; - - if (controlValues.primaryAction) { - normalized.primaryAction = sanitizeAction( - controlValues.primaryAction as Action, - ); - } - - if (controlValues.secondaryAction) { - normalized.secondaryAction = sanitizeAction( - controlValues.secondaryAction as Action, - ); - } - - if (controlValues.redirect) { - normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); - } - - if (typeof normalized === 'object' && normalized !== null) { - return Object.fromEntries( - Object.entries(normalized).filter(([_, value]) => value !== null), - ); - } - - return normalized; -} - -function sanitizeEmail(controlValues: Record) { - if (!controlValues) return controlValues; - - const emailControls: Record = {}; - - /* - * if (controlValues.body != null) { - * emailControls.body = controlValues.body || ''; - * } - */ - emailControls.subject = controlValues.subject || ''; - emailControls.body = controlValues.body || EMPTY_TIP_TAP_OBJECT; - emailControls.data = controlValues.data || null; - - return emailControls; -} - -function sanitizeSms(controlValues: Record) { - if (!controlValues) return controlValues; - - return { - body: controlValues.body || '', - data: controlValues.data || null, - }; -} - -function sanitizePush(controlValues: Record) { - if (!controlValues) return controlValues; - - const mappedValues = { - subject: controlValues.subject || '', - body: controlValues.body || '', - data: controlValues.data || null, - }; - - if (typeof mappedValues === 'object' && mappedValues !== null) { - return Object.fromEntries( - Object.entries(mappedValues).filter(([_, value]) => value !== null), - ); - } - - return mappedValues; -} - -function sanitizeChat(controlValues: Record) { - if (!controlValues) return controlValues; - - return { - body: controlValues.body || '', - data: controlValues.data || null, - }; -} - -function sanitizeDigest(controlValues: Record) { - if (!controlValues) return controlValues; - - const mappedValues = { - cron: controlValues.cron || '', - amount: controlValues.amount || 0, - unit: controlValues.unit || '', - digestKey: controlValues.digestKey || '', - data: controlValues.data || null, - lookBackWindow: controlValues.lookBackWindow - ? { - amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, - unit: (controlValues.lookBackWindow as LookBackWindow).unit || '', - } - : null, - }; - - if (typeof mappedValues === 'object' && mappedValues !== null) { - return Object.fromEntries( - Object.entries(mappedValues).filter(([_, value]) => value !== null), - ); - } - - return mappedValues; -} - -/** - * Sanitizes control values received from client-side forms into a clean minimal object. - * This function processes potentially invalid form data that may contain default/placeholder values - * and transforms it into a standardized format suitable for preview generation. - * - * @example - * // Input from form with default values: - * { - * subject: "Hello", - * body: null, - * unusedField: "test" - * } - * - * // Normalized output: - * { - * subject: "Hello", - * body: " " - * } - * - */ -export function sanitizePreviewControlValues( - controlValues: Record, - stepType: string, -): Record | null { - if (!controlValues) { - return null; - } - let normalizedValues: Record; - switch (stepType) { - case 'in_app': - normalizedValues = sanitizeInApp(controlValues); - break; - case 'email': - normalizedValues = sanitizeEmail(controlValues); - break; - case 'sms': - normalizedValues = sanitizeSms(controlValues); - break; - case 'push': - normalizedValues = sanitizePush(controlValues); - break; - case 'chat': - normalizedValues = sanitizeChat(controlValues); - break; - case 'digest': - normalizedValues = sanitizeDigest(controlValues); - break; - default: - normalizedValues = controlValues; - } - - return normalizedValues; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0cdf38e3f1..b9909c723e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,12 @@ importers: '@upstash/ratelimit': specifier: ^0.4.4 version: 0.4.4 + ajv: + specifier: ^8.12.0 + version: 8.13.0 + ajv-formats: + specifier: ^2.1.1 + version: 2.1.1(ajv@8.13.0) axios: specifier: ^1.6.8 version: 1.6.8 @@ -2727,6 +2733,9 @@ importers: class-validator: specifier: 0.14.1 version: 0.14.1 + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 date-fns: specifier: ^2.29.2 version: 2.29.3 From 1a23b26ccc111abcefef7ae4d14c457e754081e3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 11:37:43 +0200 Subject: [PATCH 10/25] fix(dashboard): should refetch when no activity jobs available yet --- .../test-workflow/test-workflow-logs-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx index 905ece44650..0e98f0071f7 100644 --- a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx +++ b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx @@ -39,7 +39,7 @@ export const TestWorkflowLogsSidebar = ({ transactionId }: TestWorkflowLogsSideb ); // Only stop refetching if we have an activity and it's not pending - setShouldRefetch(isPending); + setShouldRefetch(isPending || !activity?.jobs?.length); queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activity._id], From 35e1933c23d0fc74f19679d47a29edafc91e02c6 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 11:53:06 +0200 Subject: [PATCH 11/25] fix(dashboard): navigate root page post sync to env (#7350) --- apps/dashboard/src/hooks/use-sync-workflow.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/hooks/use-sync-workflow.tsx b/apps/dashboard/src/hooks/use-sync-workflow.tsx index cf58ffbf456..cce12d5142a 100644 --- a/apps/dashboard/src/hooks/use-sync-workflow.tsx +++ b/apps/dashboard/src/hooks/use-sync-workflow.tsx @@ -11,11 +11,14 @@ import { WorkflowOriginEnum, WorkflowStatusEnum } from '@novu/shared'; import { useMutation } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { useNavigate } from 'react-router-dom'; +import { buildRoute, ROUTES } from '@/utils/routes'; export function useSyncWorkflow(workflow: WorkflowResponseDto | WorkflowListResponseDto) { const { currentEnvironment, oppositeEnvironment, switchEnvironment } = useEnvironment(); const [isLoading, setIsLoading] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); + const navigate = useNavigate(); let loadingToast: string | number | undefined = undefined; @@ -79,7 +82,10 @@ export function useSyncWorkflow(workflow: WorkflowResponseDto | WorkflowListResp actionLabel={`Switch to ${environment?.name}`} onAction={() => { close(); - switchEnvironment(environment?.slug || ''); + const targetSlug = environment?.slug || ''; + switchEnvironment(targetSlug); + + navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: targetSlug })); }} onClose={close} /> From 6ba756adda39e8c7c160cfd965092254d4efd28c Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Mon, 23 Dec 2024 12:01:46 +0200 Subject: [PATCH 12/25] fix(dashboard): Tweak arbitrary variable handling (#7351) --- .../build-payload-schema.usecase.ts | 14 +++----------- .../build-available-variable-schema.usecase.ts | 9 ++------- .../src/app/workflows-v2/util/jsonToSchema.ts | 12 ++++++++---- .../workflow-editor/steps/email/maily.tsx | 16 +++++++++++----- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts index 5cd80c861e0..33f841a8f0f 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts @@ -5,7 +5,7 @@ import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import { flattenObjectValues } from '../../util/utils'; import { pathsToObject } from '../../util/path-to-object'; import { extractLiquidTemplateVariables } from '../../util/template-parser/liquid-parser'; -import { convertJsonToSchemaWithDefaults } from '../../util/jsonToSchema'; +import { convertJsonToSchemaWithDefaults, emptyJsonSchema } from '../../util/jsonToSchema'; import { BuildPayloadSchemaCommand } from './build-payload-schema.command'; import { transformMailyContentToLiquid } from '../generate-preview/transform-maily-content-to-liquid'; import { isStringTipTapNode } from '../../util/tip-tap.util'; @@ -19,20 +19,12 @@ export class BuildPayloadSchema { const controlValues = await this.buildControlValues(command); if (!controlValues.length) { - return { - type: 'object', - properties: {}, - additionalProperties: true, - }; + return emptyJsonSchema(); } const templateVars = await this.processControlValues(controlValues); if (templateVars.length === 0) { - return { - type: 'object', - properties: {}, - additionalProperties: true, - }; + return emptyJsonSchema(); } const variablesExample = pathsToObject(templateVars, { diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts index 54647d61040..520c1122e85 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -7,6 +7,7 @@ import { BuildAvailableVariableSchemaCommand } from './build-available-variable- import { parsePayloadSchema } from '../../shared/parse-payload-schema'; import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; +import { emptyJsonSchema } from '../../util/jsonToSchema'; @Injectable() export class BuildAvailableVariableSchemaUsecase { @@ -70,13 +71,7 @@ export class BuildAvailableVariableSchemaUsecase { } if (workflow.payloadSchema) { - return ( - parsePayloadSchema(workflow.payloadSchema, { safe: true }) || { - type: 'object', - properties: {}, - additionalProperties: true, - } - ); + return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || emptyJsonSchema(); } return this.buildPayloadSchema.execute( diff --git a/apps/api/src/app/workflows-v2/util/jsonToSchema.ts b/apps/api/src/app/workflows-v2/util/jsonToSchema.ts index ed9a7b0264b..d3617cfd730 100644 --- a/apps/api/src/app/workflows-v2/util/jsonToSchema.ts +++ b/apps/api/src/app/workflows-v2/util/jsonToSchema.ts @@ -1,5 +1,13 @@ import { JSONSchemaDefinition, JSONSchemaDto } from '@novu/shared'; +export function emptyJsonSchema(): JSONSchemaDto { + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; +} + export function convertJsonToSchemaWithDefaults(unknownObject?: Record) { if (!unknownObject) { return {}; @@ -8,10 +16,6 @@ export function convertJsonToSchemaWithDefaults(unknownObject?: Record): JSONSchemaDto { const schema: JSONSchemaDto = { type: 'object', diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx index c21fd18c6a8..499e7ae7d75 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx @@ -85,7 +85,7 @@ export const Maily = (props: MailyProps) => { variables={({ query, editor, from }) => { const queryWithoutSuffix = query.replace(/}+$/, ''); - const autoAddVariable = () => { + function addInlineVariable() { if (!query.endsWith('}}')) { return; } @@ -98,9 +98,15 @@ export const Maily = (props: MailyProps) => { editor?.commands.deleteRange({ from, to }); editor?.commands.insertContent({ type: 'variable', - attrs: { id: queryWithoutSuffix, label: null, fallback: null, showIfKey: null }, + attrs: { + id: queryWithoutSuffix, + label: null, + fallback: null, + showIfKey: null, + required: false, + }, }); - }; + } const filteredVariables: { name: string; required: boolean }[] = []; @@ -110,7 +116,7 @@ export const Maily = (props: MailyProps) => { filteredVariables.push({ name: queryWithoutSuffix, required: false }); } - autoAddVariable(); + addInlineVariable(); return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); } @@ -123,7 +129,7 @@ export const Maily = (props: MailyProps) => { filteredVariables.push({ name: queryWithoutSuffix, required: false }); } - autoAddVariable(); + addInlineVariable(); return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); }} contentJson={field.value ? JSON.parse(field.value) : undefined} From 0e065dfa8983b7558b8c6f6dd6bd5f1040bb4be3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 12:28:03 +0200 Subject: [PATCH 13/25] fix(dashboard): clicking outside dropdown closes the whole edit sidebar (#7352) --- apps/dashboard/src/components/in-app-action-dropdown.tsx | 4 ++-- .../src/components/primitives/form/avatar-picker.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/in-app-action-dropdown.tsx b/apps/dashboard/src/components/in-app-action-dropdown.tsx index 787adf40edc..a61525783cb 100644 --- a/apps/dashboard/src/components/in-app-action-dropdown.tsx +++ b/apps/dashboard/src/components/in-app-action-dropdown.tsx @@ -51,7 +51,7 @@ export const InAppActionDropdown = ({ onMenuItemClick }: { onMenuItemClick?: () return ( <> - +
{!primaryAction && !secondaryAction && ( @@ -168,7 +168,7 @@ const ConfigureActionPopover = (props: ComponentProps & { ); return ( - +
diff --git a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx index d0780010bad..d4f9a16ffaa 100644 --- a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx +++ b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx @@ -53,7 +53,7 @@ export const AvatarPicker = forwardRef( return (
- + - - - +
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx index 92f6b83024a..848be0f7a96 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx @@ -7,6 +7,7 @@ import { CustomStepControls } from '@/components/workflow-editor/steps/controls/ import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { ChatEditor } from '@/components/workflow-editor/steps/chat/chat-editor'; import { ChatEditorPreview } from '@/components/workflow-editor/steps/chat/chat-editor-preview'; +import { useEditorPreview } from '../use-editor-preview'; export const ChatTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const ChatTabs = (props: StepEditorProps) => { const isNovuCloud = !!(workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema); const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,7 +30,15 @@ export const ChatTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { previewContent={previewContent} tabsValue={tabsValue} onTabChange={setTabsValue} + previewStep={previewStep} /> ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx index bdcf1fcb5e7..bb47108eca2 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor-preview.tsx @@ -1,12 +1,7 @@ -import { ChannelTypeEnum, type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; -import { CSSProperties, useEffect, useRef, useState } from 'react'; +import { ChannelTypeEnum, GeneratePreviewResponseDto } from '@novu/shared'; +import { useState } from 'react'; +import { RiMacLine, RiSmartphoneFill } from 'react-icons/ri'; -import { Code2 } from '@/components/icons/code-2'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; -import { Button } from '@/components/primitives/button'; -import { Editor } from '@/components/primitives/editor'; -import { Skeleton } from '@/components/primitives/skeleton'; -import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { EmailPreviewBody, EmailPreviewBodyMobile, @@ -16,54 +11,28 @@ import { EmailPreviewSubjectMobile, } from '@/components/workflow-editor/steps/email/email-preview'; import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; +import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { TabsContent } from '@radix-ui/react-tabs'; -import { loadLanguage } from '@uiw/codemirror-extensions-langs'; -import { RiMacLine, RiSmartphoneFill } from 'react-icons/ri'; -import { useEditorPreview } from '../use-editor-preview'; import { Separator } from '@/components/primitives/separator'; - -const getInitialAccordionValue = (value: string) => { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { Skeleton } from '@/components/primitives/skeleton'; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; type EmailEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -export const EmailEditorPreview = ({ workflow, step, formValues }: EmailEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, isPreviewPending, previewData, previewStep } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); +export const EmailEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: EmailEditorPreviewProps) => { const [activeTab, setActiveTab] = useState('desktop'); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); return ( @@ -115,46 +84,7 @@ export const EmailEditorPreview = ({ workflow, step, formValues }: EmailEditorPr )} - - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index e549af18818..c8ce24b76ff 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -6,6 +6,7 @@ import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/ema import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; +import { useEditorPreview } from '../use-editor-preview'; export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const EmailTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,10 +30,19 @@ export const EmailTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; type InAppEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const InAppEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: InAppEditorPreviewProps) => { return (
@@ -63,46 +27,7 @@ export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPr In-App template editor
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx index 2ee9cf46e3f..50cc98a4179 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx @@ -6,6 +6,7 @@ import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { WorkflowOriginEnum } from '@/utils/enums'; +import { useEditorPreview } from '../use-editor-preview'; export const InAppTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const InAppTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,10 +30,19 @@ export const InAppTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; +import { GeneratePreviewResponseDto } from '@novu/shared'; type PushEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const PushEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending, +}: PushEditorPreviewProps) => { return (
@@ -70,46 +34,7 @@ export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPrev className="w-full px-3" />
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx index ad9fb2a1e39..23a725daaaf 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -6,6 +6,7 @@ import { CustomStepControls } from '../controls/custom-step-controls'; import { TemplateTabs } from '../template-tabs'; import { PushEditorPreview } from './push-editor-preview'; import { useFormContext } from 'react-hook-form'; +import { useEditorPreview } from '../use-editor-preview'; export const PushTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -15,6 +16,12 @@ export const PushTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -22,10 +29,19 @@ export const PushTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( void; + onUpdate: () => void; +}; + +export const ConfigurePreviewAccordion = ({ + editorValue, + setEditorValue, + onUpdate, +}: ConfigurePreviewAccordionProps) => { + const [accordionValue, setAccordionValue] = useState('payload'); + const [payloadError, setPayloadError] = useState(''); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + const timeout = setTimeout(() => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, 0); + + return () => clearTimeout(timeout); + }, [editorValue]); + + return ( + + + +
+ + Configure preview +
+
+ + + {payloadError &&

{payloadError}

} + +
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx index 8f28420699b..8848158b799 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx @@ -1,61 +1,25 @@ -import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; -import { CSSProperties, useEffect, useRef, useState } from 'react'; - import { Sms } from '@/components/icons'; -import { Code2 } from '@/components/icons/code-2'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; -import { Button } from '@/components/primitives/button'; -import { Editor } from '@/components/primitives/editor'; import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; -import { loadLanguage } from '@uiw/codemirror-extensions-langs'; -import { useEditorPreview } from '../use-editor-preview'; import { InlineToast } from '@/components/primitives/inline-toast'; import { TabsSection } from '@/components/workflow-editor/steps/tabs-section'; - -const getInitialAccordionValue = (value: string) => { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; +import { GeneratePreviewResponseDto } from '@novu/shared'; type SmsEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const SmsEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: SmsEditorPreviewProps) => { return (
@@ -70,46 +34,7 @@ export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPrevie className="w-full px-3" />
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx index 5bda1b6e89f..dc32dd5730c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx @@ -6,6 +6,7 @@ import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { WorkflowOriginEnum } from '@novu/shared'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; +import { useEditorPreview } from '../use-editor-preview'; export const SmsTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -13,6 +14,12 @@ export const SmsTabs = (props: StepEditorProps) => { const form = useFormContext(); const [tabsValue, setTabsValue] = useState('editor'); + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; @@ -23,7 +30,15 @@ export const SmsTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { previewContent={previewContent} tabsValue={tabsValue} onTabChange={setTabsValue} + previewStep={previewStep} /> ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx index 3f3b31fdea3..bbe587ec1ce 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx @@ -1,6 +1,7 @@ import { Cross2Icon } from '@radix-ui/react-icons'; import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; +import React, { useEffect } from 'react'; import { Notification5Fill } from '@/components/icons'; import { Button } from '@/components/primitives/button'; @@ -12,11 +13,26 @@ interface TemplateTabsProps { previewContent?: React.ReactNode; tabsValue: string; onTabChange: (tab: string) => void; + previewStep?: () => void; } -export const TemplateTabs = ({ editorContent, previewContent, tabsValue, onTabChange }: TemplateTabsProps) => { +export const TemplateTabs = ({ + editorContent, + previewContent, + tabsValue, + onTabChange, + previewStep, +}: TemplateTabsProps) => { const navigate = useNavigate(); + useEffect(() => { + // We reload the preview when the tab changes to get the latest values + if (tabsValue === 'preview') { + previewStep?.(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabsValue]); + return (
diff --git a/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx index 73e7c78037c..181b33fe62f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import * as Sentry from '@sentry/react'; +import isEqual from 'lodash.isequal'; import { usePreviewStep } from '@/hooks/use-preview-step'; import { useDataRef } from '@/hooks/use-data-ref'; @@ -20,7 +21,10 @@ export const useEditorPreview = ({ isPending: isPreviewPending, } = usePreviewStep({ onSuccess: (res) => { - setEditorValue(JSON.stringify(res.previewPayloadExample, null, 2)); + const newValue = JSON.stringify(res.previewPayloadExample, null, 2); + if (!isEqual(editorValue, newValue)) { + setEditorValue(newValue); + } }, onError: (error) => { Sentry.captureException(error); diff --git a/apps/dashboard/src/hooks/use-preview-step.ts b/apps/dashboard/src/hooks/use-preview-step.ts index a31693f44ab..8fb2bb7590f 100644 --- a/apps/dashboard/src/hooks/use-preview-step.ts +++ b/apps/dashboard/src/hooks/use-preview-step.ts @@ -11,11 +11,13 @@ export const usePreviewStep = ({ onError, }: { onSuccess?: (data: GeneratePreviewResponseDto) => void; onError?: (error: Error) => void } = {}) => { const { currentEnvironment } = useEnvironment(); - const { mutateAsync, isPending, error, data } = useMutation({ - mutationFn: (args: PreviewStepParameters) => previewStep({ environment: currentEnvironment!, ...args }), - onSuccess, - onError, - }); + const { mutateAsync, isPending, error, data } = useMutation( + { + mutationFn: (args: PreviewStepParameters) => previewStep({ environment: currentEnvironment!, ...args }), + onSuccess, + onError, + } + ); return { previewStep: mutateAsync, From d2f993b74c0a0d8d0f3a5e0a5bc21a8e485e0ab1 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 23 Dec 2024 16:33:56 +0200 Subject: [PATCH 21/25] style(dashboard): remove icons for consistency (#7356) --- .../workflow-editor/steps/digest/digest-key.tsx | 6 +----- .../workflow-editor/steps/digest/digest-window.tsx | 8 +------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx index bdaefd21f08..823e265afe3 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; -import { RiAccountPinBoxFill } from 'react-icons/ri'; import { autocompletion } from '@codemirror/autocomplete'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form'; @@ -25,10 +24,7 @@ export const DigestKey = () => { <> - - - Aggregated by - + Aggregated by diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx index a34042728d7..eb5e43cebfe 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { Tabs } from '@radix-ui/react-tabs'; -import { RiCalendarScheduleFill } from 'react-icons/ri'; import { FieldValues, useFormContext } from 'react-hook-form'; import { TimeUnitEnum } from '@novu/shared'; @@ -67,12 +66,7 @@ export const DigestWindow = () => { return (
- - - - Digest window - - + Digest window Date: Mon, 23 Dec 2024 17:21:49 +0200 Subject: [PATCH 22/25] feat(api-service): remove skip ref (#7357) --- .../app/workflows-v2/shared/schemas/chat-control.schema.ts | 4 ++-- .../app/workflows-v2/shared/schemas/delay-control.schema.ts | 4 ++-- .../workflows-v2/shared/schemas/digest-control.schema.ts | 6 +++--- .../app/workflows-v2/shared/schemas/email-control.schema.ts | 4 ++-- .../workflows-v2/shared/schemas/in-app-control.schema.ts | 4 ++-- .../app/workflows-v2/shared/schemas/push-control.schema.ts | 4 ++-- .../app/workflows-v2/shared/schemas/skip-control.schema.ts | 3 --- .../app/workflows-v2/shared/schemas/sms-control.schema.ts | 4 ++-- 8 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts index a05dd9e1021..07ad1dcef2f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; export const chatControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), body: z.string(), }) .strict(); diff --git a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts index 73df64df4fb..bae03fffe38 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts @@ -8,11 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; export const delayControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), type: z.enum(['regular']).default('regular'), amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum), diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index c9fc750cd52..e47a80888c8 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -8,11 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; const digestRegularControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), amount: z.union([z.number().min(1), z.string().min(1)]), unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), digestKey: z.string().optional(), @@ -27,7 +27,7 @@ const digestRegularControlZodSchema = z .strict(); const digestTimedControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), cron: z.string().min(1), digestKey: z.string().optional(), }) diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 7cf7b21e093..0a3227d6c91 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -2,11 +2,11 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@no import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; -import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; export const emailControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), /* * todo: we need to validate the email editor (body) by type and not string, * updating it to TipTapSchema will break the existing upsert issues generation diff --git a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts index 0d827963e74..1c48225e5a5 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipStepUiSchema, skipZodSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; const redirectZodSchema = z .object({ @@ -23,7 +23,7 @@ const actionZodSchema = z export const inAppControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), subject: z.string().optional(), body: z.string(), avatar: z.string().optional(), diff --git a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts index 2ff49d9ec6b..5e51b0e319c 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; export const pushControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), subject: z.string(), body: z.string(), }) diff --git a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts index 28fa246e337..98f59ee6033 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts @@ -1,7 +1,4 @@ import { UiSchemaGroupEnum, UiSchema, UiComponentEnum } from '@novu/shared'; -import { z } from 'zod'; - -export const skipZodSchema = z.object({}).catchall(z.unknown()).optional(); export const skipStepUiSchema = { group: UiSchemaGroupEnum.SKIP, diff --git a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts index 36ebe4ff5d7..a9d0911a5dd 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipZodSchema, skipStepUiSchema } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; export const smsControlZodSchema = z .object({ - skip: skipZodSchema, + skip: z.object({}).catchall(z.unknown()).optional(), body: z.string(), }) .strict(); From 569f827ed84d81eec9287d2cff5a61c0fd88fb96 Mon Sep 17 00:00:00 2001 From: George Desipris <73396808+desiprisg@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:29:14 +0200 Subject: [PATCH 23/25] fix(api-service): Add marks to tiptap zod schema (#7339) --- .../hydrate-email-schema.usecase.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts index 1d0f66fecac..0057a2f63fa 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts @@ -15,7 +15,9 @@ export class HydrateEmailSchemaUseCase { nestedForPlaceholders: {}, regularPlaceholdersToDefaultValue: {}, }; - const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)); + + // TODO: Aligned Zod inferred type and TipTapNode to remove the need of a type assertion + const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)) as TipTapNode; if (emailEditorSchema.content) { this.transformContentInPlace(emailEditorSchema.content, command.fullPayloadForRender, placeholderAggregation); } @@ -226,12 +228,24 @@ export class HydrateEmailSchemaUseCase { } } -export const TipTapSchema = z.object({ - type: z.string().optional(), - content: z.array(z.lazy(() => TipTapSchema)).optional(), - text: z.string().optional(), - attrs: z.record(z.unknown()).optional(), -}); +export const TipTapSchema = z + .object({ + type: z.string().optional(), + content: z.array(z.lazy(() => TipTapSchema)).optional(), + text: z.string().optional(), + marks: z + .array( + z + .object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + .passthrough() + ) + .optional(), + attrs: z.record(z.unknown()).optional(), + }) + .passthrough(); const buildLiquidJSDefault = (variableName: string, fallback?: string) => `{{ ${variableName}${fallback ? ` | default: '${fallback}'` : ''} }}`; From 1fed04ed019ae16dca8d0f86f4c70591c9c7041e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Mon, 23 Dec 2024 17:26:10 +0100 Subject: [PATCH 24/25] fix(api-service): digest schema - remove the schema defaults as it doesn't work with the framework ajv validation (#7334) --- .../construct-framework-workflow.usecase.ts | 22 +++++++++++++++++-- .../digest-output-renderer.usecase.ts | 13 +++-------- .../workflows/preview-step-response.dto.ts | 2 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index dcf3f1e3eec..e803139424c 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { workflow } from '@novu/framework/express'; import { ActionStep, ChannelStep, JsonSchema, Step, StepOptions, StepOutput, Workflow } from '@novu/framework/internal'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; -import { StepTypeEnum } from '@novu/shared'; +import { JSONSchemaDefinition, JSONSchemaDto, StepTypeEnum } from '@novu/shared'; import { Instrument, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import _ from 'lodash'; @@ -18,6 +18,7 @@ import { import { DelayOutputRendererUsecase } from '../output-renderers/delay-output-renderer.usecase'; import { DigestOutputRendererUsecase } from '../output-renderers/digest-output-renderer.usecase'; import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service'; +import { isMatchingJsonSchema } from '../../../workflows-v2/util/jsonToSchema'; const LOG_CONTEXT = 'ConstructFrameworkWorkflow'; @@ -183,8 +184,25 @@ export class ConstructFrameworkWorkflow { staticStep: NotificationStepEntity, fullPayloadForRender: FullPayloadForRender ): Required[2]> { + const stepOptions = this.constructCommonStepOptions(staticStep, fullPayloadForRender); + + let controlSchema = stepOptions.controlSchema as JSONSchemaDefinition; + const stepType = staticStep.template!.type; + + /* + * because of the known AJV issue with anyOf, we need to find the first schema that matches the control values + * ref: https://ajv.js.org/guide/modifying-data.html#assigning-defaults + */ + if (stepType === StepTypeEnum.DIGEST && typeof controlSchema === 'object' && controlSchema.anyOf) { + const fistSchemaMatch = controlSchema.anyOf.find((item) => { + return isMatchingJsonSchema(item, staticStep.controlVariables); + }); + controlSchema = fistSchemaMatch ?? controlSchema.anyOf[0]; + } + return { - ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), + ...stepOptions, + controlSchema: controlSchema as JsonSchema, }; } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts index b7e44b3f826..d2944007364 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts @@ -14,22 +14,15 @@ export class DigestOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): DigestRenderOutput { const parse: DigestControlSchemaType = digestControlZodSchema.parse(renderCommand.controlValues); - if ( - isDigestRegularControl(parse) && - parse.amount && - parse.unit && - parse.lookBackWindow && - parse.lookBackWindow.amount && - parse.lookBackWindow.unit - ) { + if (isDigestRegularControl(parse) && parse.amount && parse.unit) { return { amount: parse.amount as number, unit: parse.unit, digestKey: parse.digestKey, - lookBackWindow: { + ...(parse.lookBackWindow && { amount: parse.lookBackWindow.amount, unit: parse.lookBackWindow.unit, - }, + }), }; } if (isDigestTimedControl(parse) && parse.cron) { diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts index 4b627a0474c..863dc455e26 100644 --- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts +++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts @@ -43,7 +43,7 @@ class DigestRegularOutput { amount: number; unit: TimeUnitEnum; digestKey?: string; - lookBackWindow: { + lookBackWindow?: { amount: number; unit: TimeUnitEnum; }; From 29b820a22057e819b1a84a08a1d3790013e56901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Mon, 23 Dec 2024 17:30:00 +0100 Subject: [PATCH 25/25] fix(dashboard): fixed view execution logs button triggering the workflow (#7335) Co-authored-by: Dima Grossman --- .../shared/schemas/digest-control.schema.ts | 4 +- .../components/activity/activity-panel.tsx | 43 ++++++++++++++++-- .../components/primitives/inline-toast.tsx | 2 +- .../test-workflow-logs-sidebar.tsx | 44 +++++++------------ .../dashboard/src/hooks/use-fetch-activity.ts | 12 ++++- 5 files changed, 68 insertions(+), 37 deletions(-) diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index e47a80888c8..733ffeda298 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -14,12 +14,12 @@ const digestRegularControlZodSchema = z .object({ skip: z.object({}).catchall(z.unknown()).optional(), amount: z.union([z.number().min(1), z.string().min(1)]), - unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), + unit: z.nativeEnum(TimeUnitEnum), digestKey: z.string().optional(), lookBackWindow: z .object({ amount: z.number().min(1), - unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), + unit: z.nativeEnum(TimeUnitEnum), }) .strict() .optional(), diff --git a/apps/dashboard/src/components/activity/activity-panel.tsx b/apps/dashboard/src/components/activity/activity-panel.tsx index 02be4804ae0..07f5032b316 100644 --- a/apps/dashboard/src/components/activity/activity-panel.tsx +++ b/apps/dashboard/src/components/activity/activity-panel.tsx @@ -1,15 +1,20 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { motion } from 'motion/react'; import { RiPlayCircleLine, RiRouteFill } from 'react-icons/ri'; +import { IActivityJob, JobStatusEnum } from '@novu/shared'; + import { ActivityJobItem } from './activity-job-item'; import { InlineToast } from '../primitives/inline-toast'; import { useFetchActivity } from '@/hooks/use-fetch-activity'; import { ActivityOverview } from './components/activity-overview'; import { cn } from '../../utils/ui'; -import { IActivityJob } from '@novu/shared'; import { Skeleton } from '../primitives/skeleton'; +import { QueryKeys } from '@/utils/query-keys'; +import { useEnvironment } from '@/context/environment/hooks'; export interface ActivityPanelProps { - activityId: string; + activityId?: string; onActivitySelect: (activityId: string) => void; headerClassName?: string; overviewHeaderClassName?: string; @@ -21,7 +26,34 @@ export function ActivityPanel({ headerClassName, overviewHeaderClassName, }: ActivityPanelProps) { - const { activity, isPending, error } = useFetchActivity({ activityId }); + const queryClient = useQueryClient(); + const [shouldRefetch, setShouldRefetch] = useState(true); + const { currentEnvironment } = useEnvironment(); + const { activity, isPending, error } = useFetchActivity( + { activityId }, + { + refetchInterval: shouldRefetch ? 1000 : false, + } + ); + + useEffect(() => { + if (!activity) return; + + const isPending = activity.jobs?.some( + (job) => + job.status === JobStatusEnum.PENDING || + job.status === JobStatusEnum.QUEUED || + job.status === JobStatusEnum.RUNNING || + job.status === JobStatusEnum.DELAYED + ); + + // Only stop refetching if we have an activity and it's not pending + setShouldRefetch(isPending || !activity?.jobs?.length); + + queryClient.invalidateQueries({ + queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activityId], + }); + }, [activity, queryClient, currentEnvironment, activityId]); if (isPending) { return ( @@ -77,7 +109,10 @@ export function ActivityPanel({ ctaClassName="text-foreground-950" variant={'tip'} ctaLabel="View Execution" - onCtaClick={() => { + onCtaClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + if (activity._digestedNotificationId) { onActivitySelect(activity._digestedNotificationId); } diff --git a/apps/dashboard/src/components/primitives/inline-toast.tsx b/apps/dashboard/src/components/primitives/inline-toast.tsx index 3c791ac42ed..41ee8abc0f3 100644 --- a/apps/dashboard/src/components/primitives/inline-toast.tsx +++ b/apps/dashboard/src/components/primitives/inline-toast.tsx @@ -41,7 +41,7 @@ export interface InlineToastProps title?: string; description?: string | React.ReactNode; ctaLabel?: string; - onCtaClick?: () => void; + onCtaClick?: React.MouseEventHandler; isCtaLoading?: boolean; ctaClassName?: string; } diff --git a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx index 0e98f0071f7..d34b817fa6e 100644 --- a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx +++ b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx @@ -1,58 +1,44 @@ -import { ActivityPanel } from '@/components/activity/activity-panel'; import { useEffect, useState } from 'react'; -import { JobStatusEnum } from '@novu/shared'; import { Loader2 } from 'lucide-react'; + +import { ActivityPanel } from '@/components/activity/activity-panel'; import { WorkflowTriggerInboxIllustration } from '../../icons/workflow-trigger-inbox'; import { useFetchActivities } from '../../../hooks/use-fetch-activities'; -import { QueryKeys } from '../../../utils/query-keys'; -import { useEnvironment } from '../../../context/environment/hooks'; -import { useQueryClient } from '@tanstack/react-query'; type TestWorkflowLogsSidebarProps = { transactionId?: string; }; export const TestWorkflowLogsSidebar = ({ transactionId }: TestWorkflowLogsSidebarProps) => { - const queryClient = useQueryClient(); - const { currentEnvironment } = useEnvironment(); + const [parentActivityId, setParentActivityId] = useState(undefined); const [shouldRefetch, setShouldRefetch] = useState(true); const { activities } = useFetchActivities( { filters: transactionId ? { transactionId } : undefined, }, { - enabled: transactionId !== undefined, + enabled: !!transactionId, refetchInterval: shouldRefetch ? 1000 : false, } ); + const activityId: string | undefined = parentActivityId ?? activities?.[0]?._id; useEffect(() => { - if (!activities?.length) return; - - const activity = activities[0]; - const isPending = activity.jobs?.some( - (job) => - job.status === JobStatusEnum.PENDING || - job.status === JobStatusEnum.QUEUED || - job.status === JobStatusEnum.RUNNING || - job.status === JobStatusEnum.DELAYED - ); - - // Only stop refetching if we have an activity and it's not pending - setShouldRefetch(isPending || !activity?.jobs?.length); - - queryClient.invalidateQueries({ - queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activity._id], - }); - }, [activities]); + if (activityId) { + setShouldRefetch(false); + } + }, [activityId]); // Reset refetch when transaction ID changes useEffect(() => { + if (!transactionId) { + return; + } + setShouldRefetch(true); + setParentActivityId(undefined); }, [transactionId]); - const activityId = activities?.[0]?._id; - return (