Skip to content

Commit

Permalink
feat(api): Add preview endpoint (#6648)
Browse files Browse the repository at this point in the history
  • Loading branch information
tatarco authored Oct 22, 2024
1 parent 18cdedd commit 4b91fdf
Show file tree
Hide file tree
Showing 71 changed files with 2,301 additions and 342 deletions.
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 {
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`);
}

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

0 comments on commit 4b91fdf

Please sign in to comment.