Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Add preview endpoint #6648

Merged
merged 18 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
"@sendgrid/mail": "^8.1.0",
"@sentry/browser": "^8.33.1",
"@sentry/hub": "^7.114.0",
"@sentry/node": "^8.33.1",
"@sentry/nestjs": "^8.33.1",
"@sentry/node": "^8.33.1",
"@sentry/profiling-node": "^8.33.1",
"@sentry/tracing": "^7.40.0",
"@types/newrelic": "^9.14.0",
"@upstash/ratelimit": "^0.4.4",
"zod-to-json-schema": "^3.23.3",
"@maily-to/render": "^0.0.12",
"axios": "^1.6.8",
"bcrypt": "^5.0.0",
"body-parser": "^1.20.0",
Expand Down Expand Up @@ -91,7 +93,8 @@
"shortid": "^2.2.16",
"slugify": "^1.4.6",
"swagger-ui-express": "^4.4.0",
"twilio": "^4.14.1",
"twilio": "^4.14.1",
"zod": "^3.23.8",
"json-schema-to-ts": "^3.0.0",
"uuid": "^8.3.2"
},
Expand All @@ -105,6 +108,7 @@
"@types/bull": "^3.15.8",
"@types/chai": "^4.2.11",
"@types/express": "4.17.17",
"@types/faker": "^6.6.9",
"@types/mocha": "^10.0.2",
"@types/node": "^20.15.0",
"@types/passport-github": "^1.1.5",
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,4 @@ CLERK_PRIVATE_KEY=
CLERK_PEM_PUBLIC_KEY=

TUNNEL_BASE_ADDRESS=example.com
API_ROOT_URL=http://localhost:1337
3 changes: 2 additions & 1 deletion apps/api/src/app/bridge/bridge.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
UseInterceptors,
} from '@nestjs/common';

import { ControlValuesLevelEnum, UserSessionData, WorkflowTypeEnum } from '@novu/shared';
import { ControlValuesLevelEnum, UserSessionData, WorkflowOriginEnum, WorkflowTypeEnum } from '@novu/shared';
import { AnalyticsService, ExternalApiAccessible, UserAuthGuard, UserSession } from '@novu/application-generic';
import { ControlValuesRepository, EnvironmentRepository, NotificationTemplateRepository } from '@novu/dal';

Expand Down Expand Up @@ -75,6 +75,7 @@ export class BridgeController {
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
workflowOrigin: WorkflowOriginEnum.EXTERNAL,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { Subscriber } from '@novu/framework';
import { WorkflowOriginEnum } from '@novu/shared';

export class PreviewStepCommand extends EnvironmentWithUserCommand {
workflowId: string;
stepId: string;
controls: Record<string, unknown>;
payload: Record<string, unknown>;
subscriber?: Subscriber;
workflowOrigin: WorkflowOriginEnum;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PostActionEnum, HttpQueryKeysEnum, Event, JobStatusEnum, ExecuteOutput } from '@novu/framework';
import { Event, ExecuteOutput, HttpQueryKeysEnum, JobStatusEnum, PostActionEnum } from '@novu/framework';
import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand } from '@novu/application-generic';
import { WorkflowOriginEnum } from '@novu/shared';

import { PreviewStepCommand } from './preview-step.command';

Expand All @@ -10,44 +9,46 @@ export class PreviewStep {
constructor(private executeBridgeRequest: ExecuteBridgeRequest) {}

async execute(command: PreviewStepCommand): Promise<ExecuteOutput> {
const event = this.mapEvent(command);

const response = (await this.executeBridgeRequest.execute(
ExecuteBridgeRequestCommand.create({
environmentId: command.environmentId,
action: PostActionEnum.PREVIEW,
event,
searchParams: {
[HttpQueryKeysEnum.WORKFLOW_ID]: command.workflowId,
[HttpQueryKeysEnum.STEP_ID]: command.stepId,
},
// TODO: pass the origin from the command
workflowOrigin: WorkflowOriginEnum.EXTERNAL,
retriesLimit: 1,
})
)) as ExecuteOutput;
const event = this.buildBridgeEventPayload(command);
const executeCommand = this.createExecuteCommand(command, event);

const bridgeResult = await this.executeBridgeRequest.execute(executeCommand);

return bridgeResult as ExecuteOutput;
}

return response;
private createExecuteCommand(command: PreviewStepCommand, event: Event) {
return ExecuteBridgeRequestCommand.create({
environmentId: command.environmentId,
action: PostActionEnum.PREVIEW,
event,
searchParams: {
[HttpQueryKeysEnum.WORKFLOW_ID]: command.workflowId,
[HttpQueryKeysEnum.STEP_ID]: command.stepId,
},
workflowOrigin: command.workflowOrigin,
retriesLimit: 1,
});
}

private mapEvent(command: PreviewStepCommand): Omit<Event, 'workflowId' | 'stepId' | 'action' | 'source'> {
const payload = {
/** @deprecated - use controls instead */
inputs: command.controls || {},
private buildBridgeEventPayload(command: PreviewStepCommand): Event {
return {
inputs: {}, // @deprecated - use controls instead
controls: command.controls || {},
/** @deprecated - use payload instead */
data: command.payload || {},

data: {}, // @deprecated - use payload instead
payload: command.payload || {},
state: [
{
stepId: 'trigger',
outputs: command.payload || {},
outputs: {},
state: { status: JobStatusEnum.COMPLETED },
},
],
subscriber: {},
subscriber: command.subscriber || {},
stepId: command.stepId,
workflowId: command.workflowId,
action: PostActionEnum.PREVIEW,
};

return payload;
}
}
7 changes: 3 additions & 4 deletions apps/api/src/app/environments-v1/novu-bridge-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Injectable, Inject, Scope } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Request, Response } from 'express';

import { Client, PostActionEnum, NovuRequestHandler, Workflow } from '@novu/framework';
import { Client, NovuRequestHandler, PostActionEnum, Workflow } from '@novu/framework';
// @ts-expect-error - TODO: bundle CJS with @novu/framework
import { NovuHandler } from '@novu/framework/nest';
import { GetDecryptedSecretKey, GetDecryptedSecretKeyCommand } from '@novu/application-generic';
Expand Down Expand Up @@ -46,12 +45,12 @@ export class NovuBridgeClient {
ConstructFrameworkWorkflowCommand.create({
environmentId: req.params.environmentId,
workflowId: req.query.workflowId as string,
controlValues: req.body.controls,
})
);

workflows.push(programmaticallyConstructedWorkflow);
}

this.novuRequestHandler = new NovuRequestHandler({
frameworkName,
workflows,
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/app/environments-v1/novu-bridge.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { GetDecryptedSecretKey } from '@novu/application-generic';
import { NovuBridgeClient } from './novu-bridge-client';
import { ConstructFrameworkWorkflow } from './usecases/construct-framework-workflow';
import { NovuBridgeController } from './novu-bridge.controller';
import {
ChatOutputRendererUsecase,
EmailOutputRendererUsecase,
ExpandEmailEditorSchemaUsecase,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
SmsOutputRendererUsecase,
} from './usecases/output-renderers';

@Module({
controllers: [NovuBridgeController],
Expand All @@ -20,6 +28,13 @@ import { NovuBridgeController } from './novu-bridge.controller';
NotificationTemplateRepository,
ConstructFrameworkWorkflow,
GetDecryptedSecretKey,
InAppOutputRendererUsecase,
EmailOutputRendererUsecase,
SmsOutputRendererUsecase,
ChatOutputRendererUsecase,
PushOutputRendererUsecase,
EmailOutputRendererUsecase,
ExpandEmailEditorSchemaUsecase,
],
})
export class NovuBridgeModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export class ConstructFrameworkWorkflowCommand extends EnvironmentLevelCommand {
@IsString()
@IsDefined()
workflowId: string;

@IsObject()
@IsDefined()
controlValues: Record<string, unknown>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,45 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common';
import {
tatarco marked this conversation as resolved.
Show resolved Hide resolved
ActionStep,
ChannelStep,
ChatOutput,
DelayOutput,
DigestOutput,
EmailOutput,
InAppOutput,
PushOutput,
SmsOutput,
Step,
StepOptions,
StepOutput,
Workflow,
workflow,
} from '@novu/framework';
import { NotificationTemplateRepository, NotificationTemplateEntity, NotificationStepEntity } from '@novu/dal';
import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal';
import { StepTypeEnum } from '@novu/shared';
import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command';
import {
ChatOutputRendererUsecase,
EmailOutputRendererUsecase,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
SmsOutputRendererUsecase,
} from '../output-renderers';

@Injectable()
export class ConstructFrameworkWorkflow {
constructor(private workflowsRepository: NotificationTemplateRepository) {}
constructor(
private workflowsRepository: NotificationTemplateRepository,
private inAppOutputRendererUseCase: InAppOutputRendererUsecase,
private emailOutputRendererUseCase: EmailOutputRendererUsecase,
private smsOutputRendererUseCase: SmsOutputRendererUsecase,
private chatOutputRendererUseCase: ChatOutputRendererUsecase,
private pushOutputRendererUseCase: PushOutputRendererUsecase
) {}

async execute(command: ConstructFrameworkWorkflowCommand): Promise<Workflow> {
const dbWorkflow = await this.getDbWorkflow(command.environmentId, command.workflowId);

return this.constructFrameworkWorkflow(dbWorkflow);
}

private async getDbWorkflow(environmentId: string, workflowId: string): Promise<NotificationTemplateEntity> {
const foundWorkflow = await this.workflowsRepository.findByTriggerIdentifier(environmentId, workflowId);

if (!foundWorkflow) {
throw new InternalServerErrorException(`Workflow ${workflowId} not found`);
if (command.controlValues) {
for (const step of dbWorkflow.steps) {
step.controlVariables = command.controlValues;
}
}

return foundWorkflow;
return this.constructFrameworkWorkflow(dbWorkflow);
}

private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow {
Expand All @@ -48,6 +52,8 @@ export class ConstructFrameworkWorkflow {
}
},
{
payloadSchema: PERMISSIVE_EMPTY_SCHEMA,

/*
* TODO: Workflow options are not needed currently, given that this endpoint
* focuses on execution only. However we should reconsider if we decide to
Expand All @@ -69,11 +75,9 @@ export class ConstructFrameworkWorkflow {

const stepType = stepTemplate.type;
const { stepId } = staticStep;

if (!stepId) {
throw new InternalServerErrorException(`Step id not found for step ${staticStep.stepId}`);
}

const stepControls = stepTemplate.controls;

if (!stepControls) {
Expand All @@ -87,8 +91,7 @@ export class ConstructFrameworkWorkflow {
stepId,
// The step callback function. Takes controls and returns the step outputs
async (controlValues) => {
// TODO: insert custom in-app hydration logic here.
return controlValues as InAppOutput;
return this.inAppOutputRendererUseCase.execute({ controlValues });
},
// Step options
this.constructChannelStepOptions(staticStep)
Expand All @@ -97,35 +100,31 @@ export class ConstructFrameworkWorkflow {
return step.email(
stepId,
async (controlValues) => {
// TODO: insert custom Maily.to hydration logic here.
return controlValues as EmailOutput;
return this.emailOutputRendererUseCase.execute({ controlValues });
},
this.constructChannelStepOptions(staticStep)
);
case StepTypeEnum.SMS:
return step.inApp(
stepId,
async (controlValues) => {
// TODO: insert custom SMS hydration logic here.
return controlValues as SmsOutput;
return this.smsOutputRendererUseCase.execute({ controlValues });
},
this.constructChannelStepOptions(staticStep)
);
case StepTypeEnum.CHAT:
return step.inApp(
stepId,
async (controlValues) => {
// TODO: insert custom chat hydration logic here.
return controlValues as ChatOutput;
return this.chatOutputRendererUseCase.execute({ controlValues });
},
this.constructChannelStepOptions(staticStep)
);
case StepTypeEnum.PUSH:
return step.inApp(
stepId,
async (controlValues) => {
// TODO: insert custom push hydration logic here.
return controlValues as PushOutput;
return this.pushOutputRendererUseCase.execute({ controlValues });
},
this.constructChannelStepOptions(staticStep)
);
Expand Down Expand Up @@ -178,4 +177,19 @@ export class ConstructFrameworkWorkflow {
skip: (controlValues) => false,
};
}
private async getDbWorkflow(environmentId: string, workflowId: string): Promise<NotificationTemplateEntity> {
const foundWorkflow = await this.workflowsRepository.findByTriggerIdentifier(environmentId, workflowId);

if (!foundWorkflow) {
throw new InternalServerErrorException(`Workflow ${workflowId} not found`);
tatarco marked this conversation as resolved.
Show resolved Hide resolved
}

return foundWorkflow;
}
}
const PERMISSIVE_EMPTY_SCHEMA = {
type: 'object',
properties: {},
required: [],
additionalProperties: true,
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Concrete Renderer for Chat Preview
import { ChatRenderOutput } from '@novu/shared';
import { Injectable } from '@nestjs/common';
import { RenderCommand } from './render-command';

@Injectable()
export class ChatOutputRendererUsecase {
execute(renderCommand: RenderCommand): ChatRenderOutput {
const body = renderCommand.controlValues.body as string;

return { body };
}
}
Loading
Loading