diff --git a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.command.ts b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.command.ts index 3e75b707e2c..a58241f3812 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.command.ts @@ -1,5 +1,5 @@ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; -import { NotificationTemplateEntity } from '@novu/dal'; +import { ControlValuesEntity, NotificationTemplateEntity } from '@novu/dal'; import { ResourceOriginEnum, StepTypeEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsObject, IsOptional, IsString } from 'class-validator'; import { JSONSchemaDto } from '../../../shared/dtos/json-schema.dto'; @@ -39,4 +39,10 @@ export class BuildStepIssuesCommand extends EnvironmentWithUserObjectCommand { */ @IsOptional() optimisticSteps?: IOptimisticStepInfo[]; + + /** + * Pre-loaded control values to avoid redundant database queries + */ + @IsOptional() + preloadedControlValues?: ControlValuesEntity[]; } diff --git a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts index 091f196436e..3319aa83be9 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts @@ -57,6 +57,7 @@ export class BuildStepIssuesUsecase { controlSchema, controlsDto: controlValuesDto, stepType, + preloadedControlValues, } = command; const variableSchema = await this.buildAvailableVariableSchemaUsecase.execute( @@ -68,21 +69,26 @@ export class BuildStepIssuesUsecase { workflow: persistedWorkflow, ...(controlValuesDto ? { optimisticControlValues: controlValuesDto } : {}), ...(command.optimisticSteps ? { optimisticSteps: command.optimisticSteps } : {}), + ...(preloadedControlValues ? { preloadedControlValues } : {}), }) ); let newControlValues = controlValuesDto; if (!newControlValues) { - newControlValues = ( - await this.controlValuesRepository.findOne({ - _environmentId: user.environmentId, - _organizationId: user.organizationId, - _workflowId: persistedWorkflow?._id, - _stepId: stepInternalId, - level: ControlValuesLevelEnum.STEP_CONTROLS, - }) - )?.controls; + if (preloadedControlValues && stepInternalId) { + newControlValues = preloadedControlValues.find((cv) => cv._stepId === stepInternalId)?.controls; + } else { + newControlValues = ( + await this.controlValuesRepository.findOne({ + _environmentId: user.environmentId, + _organizationId: user.organizationId, + _workflowId: persistedWorkflow?._id, + _stepId: stepInternalId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }) + )?.controls; + } } const sanitizedControlValues = this.sanitizeControlValues(newControlValues, workflowOrigin, stepType); diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts index 6203be0085b..e311013fbbe 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts @@ -1,5 +1,5 @@ import { EnvironmentWithUserCommand } from '@novu/application-generic'; -import { NotificationTemplateEntity } from '@novu/dal'; +import { ControlValuesEntity, NotificationTemplateEntity } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; import { IsDefined, IsOptional, IsString } from 'class-validator'; import { PreviewPayloadDto } from '../../dtos'; @@ -33,4 +33,10 @@ export class BuildVariableSchemaCommand extends EnvironmentWithUserCommand { @IsOptional() previewData?: PreviewPayloadDto; + + /** + * Pre-loaded control values to avoid redundant database queries + */ + @IsOptional() + preloadedControlValues?: ControlValuesEntity[]; } 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 b5fed33c241..3484a65a2e5 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 @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import { + ControlValuesEntity, ControlValuesRepository, JsonSchemaTypeEnum, NotificationStepEntity, @@ -31,23 +32,28 @@ export class BuildVariableSchemaUsecase { @InstrumentUsecase() async execute(command: BuildVariableSchemaCommand): Promise { - const { workflow, stepInternalId, optimisticSteps, previewData } = command; + const { workflow, stepInternalId, optimisticSteps, previewData, preloadedControlValues } = command; let workflowControlValues: unknown[] = []; if (workflow) { - const controls = await this.controlValuesRepository.find( - { - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _workflowId: workflow._id, - level: ControlValuesLevelEnum.STEP_CONTROLS, - controls: { $ne: null }, - }, - { - controls: 1, - _id: 0, - } - ); + let controls: ControlValuesEntity[]; + if (preloadedControlValues) { + controls = preloadedControlValues; + } else { + controls = await this.controlValuesRepository.find( + { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _workflowId: workflow._id, + level: ControlValuesLevelEnum.STEP_CONTROLS, + controls: { $ne: null }, + }, + { + controls: 1, + _id: 0, + } + ); + } workflowControlValues = controls .flatMap((item) => item.controls) 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 a24f0112c42..063336f7b63 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 @@ -16,6 +16,7 @@ import { import { ClientSession, ControlSchemas, + ControlValuesEntity, ControlValuesRepository, NotificationGroupRepository, NotificationStepEntity, @@ -210,64 +211,95 @@ export class UpsertWorkflowUseCase { command: UpsertWorkflowCommand, existingWorkflow?: NotificationTemplateEntity ): Promise { - const steps: NotificationStep[] = []; + const { + user, + workflowDto: { origin: workflowOrigin }, + } = command; - // Build optimistic step information for sync scenarios - const optimisticSteps = command.workflowDto.steps.map((step, index) => ({ - stepId: step.stepId || this.generateUniqueStepId(step, steps.slice(0, index)), - type: step.type, - })); + let preloadedControlValues: ControlValuesEntity[] | undefined; + if (existingWorkflow) { + preloadedControlValues = await this.controlValuesRepository.find( + { + _environmentId: user.environmentId, + _organizationId: user.organizationId, + _workflowId: existingWorkflow._id, + level: ControlValuesLevelEnum.STEP_CONTROLS, + controls: { $ne: null }, + }, + { + controls: 1, + _stepId: 1, + _id: 0, + } + ); + } + + const tempSteps: NotificationStep[] = []; + const stepIds: string[] = []; for (const step of command.workflowDto.steps) { const existingStep: NotificationStepEntity | null | undefined = '_id' in step ? existingWorkflow?.steps.find((s) => !!step._id && s._templateId === step._id) : null; - const { - user, - workflowDto: { origin: workflowOrigin }, - } = command; - - const controlSchemas: ControlSchemas = existingStep?.template?.controls || stepTypeToControlSchema[step.type]; - const issues: StepIssuesDto = await this.buildStepIssuesUsecase.execute({ - workflowOrigin, - user, - stepInternalId: existingStep?._id, - workflow: existingWorkflow, - stepType: step.type, - controlSchema: controlSchemas.schema, - controlsDto: step.controlValues, - optimisticSteps, // Pass optimistic steps for variable schema building - }); - const updateStepId = existingStep?.stepId; const syncToEnvironmentCreateStepId = step.stepId; - const finalStep = { - template: { - type: step.type, - name: step.name, - controls: controlSchemas, - content: '', - }, - stepId: - updateStepId || - syncToEnvironmentCreateStepId || - this.generateUniqueStepId(step, existingWorkflow ? existingWorkflow.steps : steps), - name: step.name, - issues, - }; - - if (existingStep) { - Object.assign(finalStep, { - _id: existingStep._templateId, - _templateId: existingStep._templateId, - template: { ...finalStep.template, _id: existingStep._templateId }, - }); - } + const generatedStepId = + updateStepId || + syncToEnvironmentCreateStepId || + this.generateUniqueStepId(step, existingWorkflow ? existingWorkflow.steps : tempSteps); - steps.push(finalStep); + stepIds.push(generatedStepId); + tempSteps.push({ stepId: generatedStepId } as NotificationStep); } - return steps; + const optimisticSteps = command.workflowDto.steps.map((step, index) => ({ + stepId: stepIds[index], + type: step.type, + })); + + const stepsWithIssues = await Promise.all( + command.workflowDto.steps.map(async (step, index) => { + const existingStep: NotificationStepEntity | null | undefined = + '_id' in step ? existingWorkflow?.steps.find((s) => !!step._id && s._templateId === step._id) : null; + + const controlSchemas: ControlSchemas = existingStep?.template?.controls || stepTypeToControlSchema[step.type]; + const issues: StepIssuesDto = await this.buildStepIssuesUsecase.execute({ + workflowOrigin, + user, + stepInternalId: existingStep?._id, + workflow: existingWorkflow, + stepType: step.type, + controlSchema: controlSchemas.schema, + controlsDto: step.controlValues, + optimisticSteps, + preloadedControlValues, + }); + + const finalStep = { + template: { + type: step.type, + name: step.name, + controls: controlSchemas, + content: '', + }, + stepId: stepIds[index], + name: step.name, + issues, + }; + + if (existingStep) { + Object.assign(finalStep, { + _id: existingStep._templateId, + _templateId: existingStep._templateId, + template: { ...finalStep.template, _id: existingStep._templateId }, + }); + } + + return finalStep; + }) + ); + + return stepsWithIssues; } @Instrument() diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/digest-delay-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/digest-delay-tabs.tsx index cc58fc35a64..7363988a311 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/digest-delay-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/digest-delay-tabs.tsx @@ -8,6 +8,7 @@ import { Separator } from '@/components/primitives/separator'; import { TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { AMOUNT_KEY, CRON_KEY, TYPE_KEY, UNIT_KEY } from '@/components/workflow-editor/steps/digest-delay-tabs/keys'; +import { LookbackWindow } from '@/components/workflow-editor/steps/digest-delay-tabs/lookback-window'; import { RegularType } from '@/components/workflow-editor/steps/digest-delay-tabs/regular-type'; import { ScheduledType } from '@/components/workflow-editor/steps/digest-delay-tabs/scheduled-type'; import { EVERY_MINUTE_CRON } from '@/components/workflow-editor/steps/digest-delay-tabs/utils'; @@ -136,11 +137,17 @@ export const DigestDelayTabs = ({ isDigest = true }: { isDigest?: boolean }) => -
- +
+ + {isDigest && ( + <> + + + + )} - + { + const { control, setValue, getValues, trigger, watch } = useFormContext(); + const { saveForm } = useSaveForm(); + + const lookBackWindowWatch = watch('controlValues.lookBackWindow'); + + const lookbackType = useMemo(() => { + return deriveLookbackType(lookBackWindowWatch); + }, [lookBackWindowWatch]); + + const handleLookbackTypeChange = async (value: LookbackType) => { + if (value === 'immediately') { + setValue('controlValues.lookBackWindow', undefined, { shouldDirty: true }); + } else if (value === '5min') { + setValue(LOOKBACK_AMOUNT_KEY, 5, { shouldDirty: true }); + setValue(LOOKBACK_UNIT_KEY, TimeUnitEnum.MINUTES, { shouldDirty: true }); + } else if (value === '30min') { + setValue(LOOKBACK_AMOUNT_KEY, 30, { shouldDirty: true }); + setValue(LOOKBACK_UNIT_KEY, TimeUnitEnum.MINUTES, { shouldDirty: true }); + } else if (value === 'custom') { + const currentAmount = getValues(LOOKBACK_AMOUNT_KEY); + const currentUnit = getValues(LOOKBACK_UNIT_KEY); + + if (!currentAmount || !currentUnit || currentAmount === 5 || currentAmount === 30) { + setValue(LOOKBACK_AMOUNT_KEY, 10, { shouldDirty: true }); + setValue(LOOKBACK_UNIT_KEY, TimeUnitEnum.MINUTES, { shouldDirty: true }); + } + } + + await trigger(['controlValues.lookBackWindow']); + saveForm(); + }; + + return ( +
+ + Start digest + + ( + + + + + + )} + /> + {lookbackType === 'custom' && ( +
+ When events repeat within + saveForm()} + showError={false} + min={1} + dataTestId="lookback-window-amount-input" + isReadOnly={isReadOnly} + /> +
+ )} +
+ ); +}; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index 46b958797f8..4b2a409dcfb 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -594,6 +594,7 @@ export class SendMessageChat extends SendMessageBase { _jobId: command.jobId, tags: command.tags, severity: command.severity, + stepId: command.step.stepId, ...(command.contextKeys && { contextKeys: command.contextKeys }), ...(channelData && channelData.length > 0 && { channelData: channelData.map((data) => this.redactChannelData(data)) }), diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts index 3f640642897..823c4307456 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts @@ -210,6 +210,7 @@ export class SendMessageEmail extends SendMessageBase { payload: messagePayload, overrides, templateIdentifier: command.identifier, + stepId: command.step.stepId, _jobId: command.jobId, tags: command.tags, severity: command.severity, diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts index 25a0e667f96..ee98d9ae408 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts @@ -225,6 +225,7 @@ export class SendMessageInApp extends SendMessageBase { _templateId: command._templateId, _messageTemplateId: step.template._id, templateIdentifier: command.identifier, + stepId: command.step.stepId, transactionId: command.transactionId, providerId: integration.providerId, _feedId: step.template._feedId, diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts index c19edddf15f..9f5754ac189 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts @@ -731,6 +731,7 @@ export class SendMessagePush extends SendMessageBase { _jobId: command.jobId, tags: command.tags, severity: command.severity, + stepId: command.step?.stepId, ...(command.contextKeys && { contextKeys: command.contextKeys }), }); diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts index 411c9694a16..793a7017db1 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts @@ -171,6 +171,7 @@ export class SendMessageSms extends SendMessageBase { payload: messagePayload, overrides, templateIdentifier: command.identifier, + stepId: command.step.stepId, _jobId: command.jobId, tags: command.tags, severity: command.severity, diff --git a/libs/application-generic/src/webhooks/dtos/message-webhook.response.dto.ts b/libs/application-generic/src/webhooks/dtos/message-webhook.response.dto.ts index 1e26422e925..a8b68893804 100644 --- a/libs/application-generic/src/webhooks/dtos/message-webhook.response.dto.ts +++ b/libs/application-generic/src/webhooks/dtos/message-webhook.response.dto.ts @@ -10,6 +10,7 @@ export type MessageWebhookResponseDto = Pick< | '_notificationId' | 'actorSubscriber' | 'templateIdentifier' + | 'stepId' | 'createdAt' | 'updatedAt' | 'archivedAt' @@ -34,4 +35,5 @@ export type MessageWebhookResponseDto = Pick< webhookUrl?: string; channelData?: ChannelData; subscriberId?: string; + workflowId?: string; }; diff --git a/libs/application-generic/src/webhooks/mappers/message.mapper.ts b/libs/application-generic/src/webhooks/mappers/message.mapper.ts index c2e6a9dc7ec..b4f820fa859 100644 --- a/libs/application-generic/src/webhooks/mappers/message.mapper.ts +++ b/libs/application-generic/src/webhooks/mappers/message.mapper.ts @@ -12,6 +12,7 @@ export const messageWebhookMapper = ( | '_notificationId' | 'actorSubscriber' | 'templateIdentifier' + | 'stepId' | 'createdAt' | 'updatedAt' | 'archivedAt' @@ -73,5 +74,7 @@ export const messageWebhookMapper = ( channelData: context?.channelData, providerResponseId: context?.providerResponseId, contextKeys: message.contextKeys, + workflowId: message.templateIdentifier, + stepId: message.stepId, }; }; diff --git a/libs/dal/src/repositories/message/message.entity.ts b/libs/dal/src/repositories/message/message.entity.ts index d18c4a1a823..e7353027041 100644 --- a/libs/dal/src/repositories/message/message.entity.ts +++ b/libs/dal/src/repositories/message/message.entity.ts @@ -46,6 +46,8 @@ export class MessageEntity { templateIdentifier: string; + stepId?: string; + createdAt: string; updatedAt: string; diff --git a/libs/dal/src/repositories/message/message.schema.ts b/libs/dal/src/repositories/message/message.schema.ts index 6cdaefda96f..4b1bd83ebb0 100644 --- a/libs/dal/src/repositories/message/message.schema.ts +++ b/libs/dal/src/repositories/message/message.schema.ts @@ -34,6 +34,7 @@ const messageSchema = new Schema( ref: 'Job', }, templateIdentifier: Schema.Types.String, + stepId: Schema.Types.String, email: Schema.Types.String, subject: Schema.Types.String, cta: { diff --git a/libs/design-system/src/table/Table.tsx b/libs/design-system/src/table/Table.tsx index 66a83786688..dd3b2f7c42d 100644 --- a/libs/design-system/src/table/Table.tsx +++ b/libs/design-system/src/table/Table.tsx @@ -1,27 +1,26 @@ -import React, { useEffect, useMemo } from 'react'; -import { Skeleton, TableProps, Table as MantineTable, Pagination, Button } from '@mantine/core'; import styled from '@emotion/styled'; +import { Button, Table as MantineTable, Pagination, Skeleton, TableProps } from '@mantine/core'; +import React, { useEffect, useMemo } from 'react'; import { - useTable, + CellProps, Column, - usePagination, + IdType, + Row, TableInstance, UsePaginationInstanceProps, UsePaginationState, - Row, - CellProps, - IdType, - useRowSelect, UseRowSelectInstanceProps, UseRowSelectState, + usePagination, + useRowSelect, + useTable, } from 'react-table'; -import { useDataRef } from '../hooks/useDataRef'; - -import useStyles from './Table.styles'; import { colors } from '../config'; -import { DefaultCell } from './DefaultCell'; +import { useDataRef } from '../hooks/useDataRef'; import { ChevronLeft, ChevronRight } from '../icons'; import { Radio } from '../radio/Radio'; +import { DefaultCell } from './DefaultCell'; +import useStyles from './Table.styles'; const NoDataPlaceholder = styled.div` padding: 0 30px; @@ -154,7 +153,7 @@ export function Table({ width: 30, maxWidth: 30, }; - hooks.visibleColumns.push((visibleColumns) => [selectionRow, ...visibleColumns]); + hooks.visibleColumns.push((visibleColumns) => [selectionRow as any, ...visibleColumns]); } ) as unknown as UseTableProps;