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-service): polish action steps #7389

Merged
merged 6 commits into from
Dec 26, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"prebuild": "rimraf dist",
"build": "pnpm build:metadata && nest build",
"build:watch": "pnpm build:metadata && nest build --watch",
"format": "prettier --write \"src/**/*.ts\"",
"docker:build": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | BULL_MQ_PRO_NPM_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} docker buildx build --load -t novu-api --secret id=BULL_MQ_PRO_NPM_TOKEN --build-arg PACKAGE_PATH=apps/api - $DOCKER_BUILD_ARGUMENTS",
"docker:build:depot": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | depot build --build-arg PACKAGE_PATH=apps/api - -t novu-api --load",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ import {
} from '@novu/dal';
import {
ContentIssue,
CreateWorkflowDto,
JSONSchemaDto,
DEFAULT_WORKFLOW_PREFERENCES,
slugify,
StepContentIssueEnum,
StepCreateDto,
StepIssuesDto,
StepUpdateDto,
UpdateWorkflowDto,
UserSessionData,
StepTypeEnum,
WorkflowCreationSourceEnum,
Expand Down Expand Up @@ -317,11 +315,11 @@ export class UpsertWorkflowUseCase {
const sanitizedControlValues =
controlValueLocal && workflowOrigin === WorkflowOriginEnum.NOVU_CLOUD
? dashboardSanitizeControlValues(controlValueLocal, step.type) || {}
: convertEmptyStringsToNull(controlValueLocal) || {};
: frameworkSanitizeEmptyStringsToNull(controlValueLocal) || {};

const controlIssues = processControlValuesBySchema(controlSchemas?.schema, sanitizedControlValues || {});
const liquidTemplateIssues = processControlValuesByLiquid(variableSchema, controlValueLocal || {});
const customIssues = await this.processControlValuesByRules(user, step.type, controlValueLocal || {});
const customIssues = await this.processControlValuesByRules(user, step.type, sanitizedControlValues || {});
const customControlIssues = _.isEmpty(customIssues) ? {} : { controls: customIssues };

return _.merge(controlIssues, liquidTemplateIssues, customControlIssues);
Expand Down Expand Up @@ -432,13 +430,11 @@ export class UpsertWorkflowUseCase {
stepType: StepTypeEnum,
controlValues: Record<string, unknown> | null
): Promise<StepIssuesDto> {
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,
amount: controlValues?.amount as number | undefined,
unit: controlValues?.unit as string | undefined,
cron: controlValues?.cron as string | undefined,
organizationId: user.organizationId,
stepType,
})
Expand Down Expand Up @@ -584,7 +580,9 @@ function cleanObject(
);
}

function convertEmptyStringsToNull(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
function frameworkSanitizeEmptyStringsToNull(
obj: Record<string, unknown> | undefined
): Record<string, unknown> | undefined {
if (typeof obj !== 'object' || obj === null || obj === undefined) return obj;

return Object.fromEntries(
Expand All @@ -593,7 +591,7 @@ function convertEmptyStringsToNull(obj: Record<string, unknown> | undefined): Re
return [key, null];
}
if (typeof value === 'object') {
return [key, convertEmptyStringsToNull(value as Record<string, unknown>)];
return [key, frameworkSanitizeEmptyStringsToNull(value as Record<string, unknown>)];
}

return [key, value];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export class PrepareAndValidateContentUsecase {
): Promise<Record<string, ContentIssue[]>> {
const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute(
TierRestrictionsValidateCommand.create({
amount: defaultControlValues.amount as string | undefined,
amount: defaultControlValues.amount as number | undefined,
unit: defaultControlValues.unit as string | undefined,
organizationId: user.organizationId,
stepType,
Expand Down
44 changes: 34 additions & 10 deletions apps/dashboard/src/components/workflow-editor/step-utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { flatten } from 'flat';
import type {
ContentIssue,
StepCreateDto,
StepIssuesDto,
StepTypeEnum,
StepUpdateDto,
UpdateWorkflowDto,
WorkflowResponseDto,
} from '@novu/shared';
import { Step } from '@/utils/types';
import { STEP_TYPE_LABELS } from '@/utils/constants';
import { StepTypeEnum } from '@novu/shared';
import {
DEFAULT_CONTROL_DELAY_AMOUNT,
DEFAULT_CONTROL_DELAY_TYPE,
DEFAULT_CONTROL_DELAY_UNIT,
DEFAULT_CONTROL_DIGEST_AMOUNT,
DEFAULT_CONTROL_DIGEST_CRON,
DEFAULT_CONTROL_DIGEST_DIGEST_KEY,
DEFAULT_CONTROL_DIGEST_UNIT,
STEP_TYPE_LABELS,
} from '@/utils/constants';

export const getFirstBodyErrorMessage = (issues?: StepIssuesDto) => {
const stepIssuesArray = Object.entries({ ...issues?.body });
Expand Down Expand Up @@ -58,10 +67,25 @@ export const updateStepInWorkflow = (
};
};

export const createStep = (type: StepTypeEnum): Step => ({
name: STEP_TYPE_LABELS[type] + ' Step',
stepId: '',
slug: '_st_',
type,
_id: crypto.randomUUID(),
});
export const createStep = (type: StepTypeEnum): StepCreateDto => {
Copy link
Contributor Author

@djabarovgeorge djabarovgeorge Dec 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we used the wrong type here, this is why we passed redundant data to the API such as slug prefix

const controlValue: Record<string, unknown> = {};

if (type === StepTypeEnum.DIGEST) {
controlValue.amount = DEFAULT_CONTROL_DIGEST_AMOUNT;
controlValue.unit = DEFAULT_CONTROL_DIGEST_UNIT;
controlValue.digestKey = DEFAULT_CONTROL_DIGEST_DIGEST_KEY;
controlValue.cron = DEFAULT_CONTROL_DIGEST_CRON;
}

if (type === StepTypeEnum.DELAY) {
controlValue.amount = DEFAULT_CONTROL_DELAY_AMOUNT;
controlValue.unit = DEFAULT_CONTROL_DELAY_UNIT;
controlValue.type = DEFAULT_CONTROL_DELAY_TYPE;
}

return {
name: STEP_TYPE_LABELS[type] + ' Step',
type,
controlValues: controlValue,
};
};
Comment on lines +70 to +91
Copy link
Contributor Author

@djabarovgeorge djabarovgeorge Dec 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summary of the findings of the best approach, why the change is necessary, and what is happening in the current implementation.

The main reason for this change is that we cannot create a step with control values during step creation. This limitation causes unexpected behavior, like the bug we are experiencing.

Currently, we cannot move schemas with default values to the Shared package and reuse them in the Dashboard because these schemas depend on Zod and Zod-to-JSON-Schema.

The solution we originally wanted—to move default values from the UiSchema to the dataSchema—is not supported natively. The solution should be Zod schema with default values (used by the Dashboard) and make certain attributes, like body, required for generating issues. However, this approach fails because zod-to-json-schema removes the required attributes if there is a default value meaning we will need to manipulate the schema manually.

This solution is reducing the amount of code. In addition once we store the default values during step creation, we wouldn't need to handle parsing them (placeholders/defaults) in the Dashboard or maintain "placeholders" or default values in the API.

Next Steps

  • Remove placeholders from the API.
  • Eliminate placeholder parsing in the Dashboard.

11 changes: 10 additions & 1 deletion apps/dashboard/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StepTypeEnum } from '@novu/shared';
import { StepTypeEnum, TimeUnitEnum } from '@novu/shared';

export const AUTOCOMPLETE_PASSWORD_MANAGERS_OFF = {
autoComplete: 'off',
Expand Down Expand Up @@ -27,3 +27,12 @@ export const STEP_TYPE_LABELS: Record<StepTypeEnum, string> = {
[StepTypeEnum.TRIGGER]: 'Trigger',
[StepTypeEnum.CUSTOM]: 'Custom',
};

export const DEFAULT_CONTROL_DELAY_AMOUNT = 42;
export const DEFAULT_CONTROL_DELAY_UNIT = TimeUnitEnum.SECONDS;
export const DEFAULT_CONTROL_DELAY_TYPE = 'regular';

export const DEFAULT_CONTROL_DIGEST_AMOUNT = 42;
export const DEFAULT_CONTROL_DIGEST_UNIT = TimeUnitEnum.SECONDS;
export const DEFAULT_CONTROL_DIGEST_CRON = '';
export const DEFAULT_CONTROL_DIGEST_DIGEST_KEY = '';
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { defaultOptions, skipStepUiSchema, skipZodSchema } from './shared';
export const delayControlZodSchema = z
.object({
skip: skipZodSchema,
type: z.enum(['regular']).default('regular'),
amount: z.union([z.number().min(1), z.string()]),
type: z.enum(['regular']),
amount: z.number().min(1),
unit: z.nativeEnum(TimeUnitEnum),
})
.strict();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ const lookBackWindowZodSchema = z
const digestRegularControlZodSchema = z
.object({
skip: skipZodSchema,
amount: z.union([z.number().min(1), z.string().min(1)]),
amount: z.number().min(1),
unit: z.nativeEnum(TimeUnitEnum),
digestKey: z.string().optional(),
lookBackWindow: lookBackWindowZodSchema.optional(),
})
.strict();
const digestTimedControlZodSchema = z
.object({
skip: z.object({}).catchall(z.unknown()).optional(),
skip: skipZodSchema,
cron: z.string().min(1),
digestKey: z.string().optional(),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { OrganizationLevelCommand } from '../../commands';

export class TierRestrictionsValidateCommand extends OrganizationLevelCommand {
@IsOptional()
@Transform(({ value }) => (value ? String(value) : value))
@IsString()
amount?: string;
@Transform(({ value }) => (value ? Number(value) : value))
@IsNumber()
amount?: number;
Comment on lines +8 to +10
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a number as we use it as number in the usecase


@IsString()
@IsOptional()
Expand Down
135 changes: 88 additions & 47 deletions libs/application-generic/src/utils/sanitize-control-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import {
SmsControlType,
InAppRedirectType,
PushControlType,
isDigestTimedControl,
DigestTimedControlType,
DigestControlSchemaType,
isDigestRegularControl,
DigestRegularControlType,
LookBackWindowType,
DelayControlType,
Expand Down Expand Up @@ -49,7 +47,7 @@ function sanitizeAction(action: InAppActionType) {
function sanitizeInApp(controlValues: InAppControlType) {
const normalized: InAppControlType = {
subject: controlValues.subject || undefined,
// Cast to string to trigger Ajv validation errors
// Cast to string to trigger Ajv validation errors - possible undefined
body: isEmpty(controlValues.body)
? (undefined as unknown as string)
: controlValues.body,
Expand Down Expand Up @@ -126,7 +124,7 @@ function sanitizeChat(controlValues: ChatControlType) {
}

function sanitizeDigest(controlValues: DigestControlSchemaType) {
if (isDigestTimedControl(controlValues)) {
if (isTimedDigestControl(controlValues)) {
const mappedValues: DigestTimedControlType = {
cron: controlValues.cron || '',
digestKey: controlValues.digestKey || '',
Expand All @@ -136,19 +134,20 @@ function sanitizeDigest(controlValues: DigestControlSchemaType) {
return filterNullishValues(mappedValues);
}

if (isDigestRegularControl(controlValues)) {
if (isRegularDigestControl(controlValues)) {
const lookBackAmount = (controlValues.lookBackWindow as LookBackWindowType)
?.amount;
const mappedValues: DigestRegularControlType = {
amount: controlValues.amount || 0,
unit: controlValues.unit || TimeUnitEnum.SECONDS,
digestKey: controlValues.digestKey || '',
// Cast to trigger Ajv validation errors - possible undefined
...(parseAmount(controlValues.amount) as { amount?: number }),
unit: controlValues.unit,
digestKey: controlValues.digestKey,
skip: controlValues.skip || undefined,
lookBackWindow: controlValues.lookBackWindow
? {
amount:
(controlValues.lookBackWindow as LookBackWindowType).amount || 0,
unit:
(controlValues.lookBackWindow as LookBackWindowType).unit ||
TimeUnitEnum.SECONDS,
// Cast to trigger Ajv validation errors - possible undefined
...(parseAmount(lookBackAmount) as { amount?: number }),
unit: (controlValues.lookBackWindow as LookBackWindowType).unit,
}
: undefined,
};
Expand Down Expand Up @@ -177,15 +176,31 @@ function sanitizeDigest(controlValues: DigestControlSchemaType) {

function sanitizeDelay(controlValues: DelayControlType) {
const mappedValues: DelayControlType = {
type: controlValues.type || 'regular',
amount: controlValues.amount || 0,
unit: controlValues.unit || TimeUnitEnum.SECONDS,
// Cast to trigger Ajv validation errors - possible undefined
...(parseAmount(controlValues.amount) as { amount?: number }),
type: controlValues.type,
unit: controlValues.unit,
skip: controlValues.skip || undefined,
};

return filterNullishValues(mappedValues);
}

function parseAmount(amount?: unknown) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safe parse just in case

try {
if (!isNumber(amount)) {
return {};
}

const numberAmount =
typeof amount === 'string' ? parseInt(amount, 10) : amount;

return { amount: numberAmount };
} catch (error) {
return amount;
}
}

function filterNullishValues<T extends Record<string, unknown>>(obj: T): T {
if (typeof obj === 'object' && obj !== null) {
return Object.fromEntries(
Expand Down Expand Up @@ -222,37 +237,63 @@ export function dashboardSanitizeControlValues(
controlValues: Record<string, unknown>,
stepType: StepTypeEnum | unknown,
): (Record<string, unknown> & { skip?: Record<string, unknown> }) | null {
if (!controlValues) {
return null;
}
let normalizedValues: Record<string, unknown>;
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);
try {
if (!controlValues) {
return null;
}

console.log('controlValues 333222 ', controlValues);
djabarovgeorge marked this conversation as resolved.
Show resolved Hide resolved
let normalizedValues: Record<string, unknown>;
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);
}

console.log('normalizedValues 333222 ', normalizedValues);
djabarovgeorge marked this conversation as resolved.
Show resolved Hide resolved

return normalizedValues;
} catch (error) {
console.error('Error sanitizing control values', error);
djabarovgeorge marked this conversation as resolved.
Show resolved Hide resolved

return controlValues;
}
}

function isNumber(value: unknown): value is number {
return !Number.isNaN(Number.parseInt(value as string, 10));
}

function isTimedDigestControl(
controlValues: unknown,
): controlValues is DigestTimedControlType {
return !isEmpty((controlValues as DigestTimedControlType)?.cron);
}

return normalizedValues;
function isRegularDigestControl(
controlValues: unknown,
): controlValues is DigestRegularControlType {
return !isTimedDigestControl(controlValues);
}
Loading