Skip to content

Commit

Permalink
bug(api): improve illegal placeholder validation
Browse files Browse the repository at this point in the history
  • Loading branch information
tatarco committed Nov 11, 2024
1 parent 0f75ef9 commit 0a708b0
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 136 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,42 @@ export class HydrateEmailSchemaUseCase {
execute(command: HydrateEmailSchemaCommand): {
hydratedEmailSchema: TipTapNode;
nestedPayload: Record<string, unknown>;
allPlaceholdersReferenced: string[];
} {
const defaultPayload: Record<string, unknown> = {};
const allPlaceholdersReferenced: string[] = [];
const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor));
if (emailEditorSchema.content) {
this.transformContentInPlace(emailEditorSchema.content, defaultPayload, command.fullPayloadForRender);
this.transformContentInPlace(
emailEditorSchema.content,
defaultPayload,
command.fullPayloadForRender,
allPlaceholdersReferenced
);
}

return { hydratedEmailSchema: emailEditorSchema, nestedPayload: this.flattenToNested(defaultPayload) };
return {
hydratedEmailSchema: emailEditorSchema,
nestedPayload: this.flattenToNested(defaultPayload),
allPlaceholdersReferenced,
};
}

private variableLogic(
masterPayload: PreviewPayload,
node: TipTapNode & { attrs: { id: string } },
node: TipTapNode & {
attrs: { id: string };
},
defaultPayload: Record<string, unknown>,
content: TipTapNode[],
index: number
index: number,
allPlaceholdersReferenced: string[]
) {
const resolvedValueRegularPlaceholder = this.getResolvedValueRegularPlaceholder(masterPayload, node);
const resolvedValueRegularPlaceholder = this.getResolvedValueRegularPlaceholder(
masterPayload,
node,
allPlaceholdersReferenced
);
defaultPayload[node.attrs.id] = resolvedValueRegularPlaceholder;
content[index] = {
type: 'text',
Expand All @@ -35,17 +53,21 @@ export class HydrateEmailSchemaUseCase {
}

private forNodeLogic(
node: TipTapNode & { attrs: { each: string } },
node: TipTapNode & {
attrs: { each: string };
},
masterPayload: PreviewPayload,
defaultPayload: Record<string, unknown>,
content: TipTapNode[],
index: number
index: number,
allPlaceholdersReferenced: string[]
) {
const itemPointerToDefaultRecord = this.collectAllItemPlaceholders(node);
const resolvedValueForPlaceholder = this.getResolvedValueForPlaceholder(
masterPayload,
node,
itemPointerToDefaultRecord
itemPointerToDefaultRecord,
allPlaceholdersReferenced
);
defaultPayload[node.attrs.each] = resolvedValueForPlaceholder;
content[index] = {
Expand All @@ -57,31 +79,39 @@ export class HydrateEmailSchemaUseCase {

private showLogic(
masterPayload: PreviewPayload,
node: TipTapNode & { attrs: { show: string } },
defaultPayload: Record<string, unknown>
node: TipTapNode & {
attrs: { show: string };
},
defaultPayload: Record<string, unknown>,
allPlaceholdersReferenced: string[]
) {
const resolvedValueShowPlaceholder = this.getResolvedValueShowPlaceholder(masterPayload, node);
const resolvedValueShowPlaceholder = this.getResolvedValueShowPlaceholder(
masterPayload,
node,
allPlaceholdersReferenced
);
defaultPayload[node.attrs.show] = resolvedValueShowPlaceholder;
node.attrs.show = resolvedValueShowPlaceholder;
}

private transformContentInPlace(
content: TipTapNode[],
defaultPayload: Record<string, unknown>,
masterPayload: PreviewPayload
masterPayload: PreviewPayload,
allPlaceholdersReferenced: string[]
) {
content.forEach((node, index) => {
if (this.isVariableNode(node)) {
this.variableLogic(masterPayload, node, defaultPayload, content, index);
this.variableLogic(masterPayload, node, defaultPayload, content, index, allPlaceholdersReferenced);
}
if (this.isForNode(node)) {
this.forNodeLogic(node, masterPayload, defaultPayload, content, index);
this.forNodeLogic(node, masterPayload, defaultPayload, content, index, allPlaceholdersReferenced);
}
if (this.isShowNode(node)) {
this.showLogic(masterPayload, node, defaultPayload);
this.showLogic(masterPayload, node, defaultPayload, allPlaceholdersReferenced);
}
if (node.content) {
this.transformContentInPlace(node.content, defaultPayload, masterPayload);
this.transformContentInPlace(node.content, defaultPayload, masterPayload, allPlaceholdersReferenced);
}
});
}
Expand All @@ -98,15 +128,15 @@ export class HydrateEmailSchemaUseCase {
return !!(node.type === 'variable' && node.attrs && 'id' in node.attrs && typeof node.attrs.id === 'string');
}

private getResolvedValueRegularPlaceholder(masterPayload: PreviewPayload, node) {
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id);
private getResolvedValueRegularPlaceholder(masterPayload: PreviewPayload, node, allPlaceholdersReferenced: string[]) {
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id, allPlaceholdersReferenced);
const { fallback } = node.attrs;

return resolvedValue || fallback || `{{${node.attrs.id}}}`;
}

private getResolvedValueShowPlaceholder(masterPayload: PreviewPayload, node) {
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.show);
private getResolvedValueShowPlaceholder(masterPayload: PreviewPayload, node, allPlaceholdersReferenced: string[]) {
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.show, allPlaceholdersReferenced);
const { fallback } = node.attrs;

return resolvedValue || fallback || `true`;
Expand All @@ -133,10 +163,13 @@ export class HydrateEmailSchemaUseCase {

private getResolvedValueForPlaceholder(
masterPayload: PreviewPayload,
node: TipTapNode & { attrs: { each: string } },
itemPointerToDefaultRecord: Record<string, string>
node: TipTapNode & {
attrs: { each: string };
},
itemPointerToDefaultRecord: Record<string, string>,
allPlaceholdersReferenced: string[]
) {
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.each);
const resolvedValue = this.getValueByPath(masterPayload, node.attrs.each, allPlaceholdersReferenced);

if (!resolvedValue) {
return [this.buildElement(itemPointerToDefaultRecord, '1'), this.buildElement(itemPointerToDefaultRecord, '2')];
Expand Down Expand Up @@ -164,8 +197,9 @@ export class HydrateEmailSchemaUseCase {
return payloadValues;
}

private getValueByPath(obj: Record<string, any>, path: string): any {
const keys = path.split('.');
private getValueByPath(obj: Record<string, any>, placeholderRef: string, allPlaceholdersReferenced: string[]): any {
const keys = placeholderRef.split('.');
allPlaceholdersReferenced.push(placeholderRef);

return keys.reduce((currentObj, key) => {
if (currentObj && typeof currentObj === 'object' && key in currentObj) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSONSchema } from 'json-schema-to-ts';
import { UiComponentEnum, UiSchema, UiSchemaGroupEnum, UiSchemaProperty } from '@novu/shared';
import { UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared';

const ABSOLUTE_AND_RELATIVE_URL_REGEX = '^(?!mailto:)(?:(https?):\\/\\/[^\\s/$.?#].[^\\s]*)|^(\\/[^\\s]*)$';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,136 @@
import { Injectable } from '@nestjs/common';
import { ContentIssue, PreviewPayload, StepContentIssueEnum } from '@novu/shared';
import { BaseCommand } from '@novu/application-generic';
import type { JSONSchema } from 'json-schema-to-ts';
import _ = require('lodash');
import { CreateMockPayloadForSingleControlValueUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase';
import { CreateMockPayloadForSingleControlValueUseCase } from '../placeholder-enrichment/create-mock-payload-for-single-value.usecase';

class BuildDefaultPayloadCommand extends BaseCommand {
controlValues?: Record<string, unknown>;
payloadValues?: PreviewPayload;
variables?: JSONSchema;
}

class BuildDefaultPayloadResponse {
previewPayload: PreviewPayload;
issues: Record<string, ContentIssue[]>;
}

function buildIssue(placeholder: string, controlValueKey: string) {
return {
issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE,
message: `Placeholder ${placeholder} is not allowed in control value ${controlValueKey}`,
variableName: controlValueKey,
};
}

function addVariableIssues(
controlValueToProblematicPlaceholders: Record<string, string[]>,
buildPayloadIssues: Record<string, ContentIssue[]>
) {
const updatedIssues = _.cloneDeep(buildPayloadIssues);

for (const controlValueKey of Object.keys(controlValueToProblematicPlaceholders)) {
for (const placeholder of controlValueToProblematicPlaceholders[controlValueKey]) {
if (updatedIssues[controlValueKey]) {
updatedIssues[controlValueKey].push(buildIssue(placeholder, controlValueKey));
} else {
updatedIssues[controlValueKey] = [buildIssue(placeholder, controlValueKey)];
}
}
}

return updatedIssues;
}

@Injectable()
export class BuildDefaultPayloadUseCase {
constructor(private payloadForSingleControlValueUseCase: CreateMockPayloadForSingleControlValueUseCase) {}
constructor(private extractSingleStringPayload: CreateMockPayloadForSingleControlValueUseCase) {}

execute(command: BuildDefaultPayloadCommand): {
previewPayload: PreviewPayload;
issues: Record<string, ContentIssue[]>;
} {
let aggregatedDefaultValues = {};
const aggregatedDefaultValuesForControl: Record<string, Record<string, unknown>> = {};
execute(command: BuildDefaultPayloadCommand): BuildDefaultPayloadResponse {
if (this.hasNoValues(command)) {
return {
previewPayload: command.payloadValues || {},
issues: {},
};
}

const flattenedValues = flattenJson(command.controlValues);
for (const controlValueKey in flattenedValues) {
if (flattenedValues.hasOwnProperty(controlValueKey)) {
const defaultPayloadForASingleControlValue = this.payloadForSingleControlValueUseCase.execute({
controlValues: flattenedValues,
controlValueKey,
});
if (defaultPayloadForASingleControlValue) {
aggregatedDefaultValuesForControl[controlValueKey] = defaultPayloadForASingleControlValue;
}
aggregatedDefaultValues = _.merge(defaultPayloadForASingleControlValue, aggregatedDefaultValues);
}
}
const aggregatedDefaultValuesForControl: Record<string, Record<string, unknown>> = {};
const controlValueToProblematicPlaceholders: Record<string, string[]> = {};
const aggregatedDefaultValues = this.buildPayload(
command,
aggregatedDefaultValuesForControl,
controlValueToProblematicPlaceholders
);
const previewPayload = _.merge(aggregatedDefaultValues, command.payloadValues);

return {
previewPayload: _.merge(aggregatedDefaultValues, command.payloadValues),
issues: this.buildVariableMissingIssueRecord(
previewPayload,
issues: this.getIssues(
aggregatedDefaultValuesForControl,
aggregatedDefaultValues,
command.payloadValues
command,
controlValueToProblematicPlaceholders
),
};
}

private getIssues(
aggregatedDefaultValuesForControl: Record<string, Record<string, unknown>>,
aggregatedDefaultValues: {},
command: BuildDefaultPayloadCommand,
controlValueToProblematicPlaceholders: Record<string, string[]>
) {
return this.buildVariableMissingIssueRecord(
aggregatedDefaultValuesForControl,
aggregatedDefaultValues,
command.payloadValues,
controlValueToProblematicPlaceholders
);
}

private buildPayload(
command: BuildDefaultPayloadCommand,
aggregatedDefaultValuesForControl: Record<string, Record<string, unknown>>,
controlValueToProblematicPlaceholders: Record<string, string[]>
) {
let aggregatedDefaultValues = {};
const flattenedValues = flattenJson(command.controlValues);
for (const controlValueKey of Object.keys(flattenedValues)) {
const result = this.extractSingleStringPayload.execute({
controlValues: flattenedValues,
controlValueKey,
variables: command.variables,
});
if (result.payload) {
aggregatedDefaultValuesForControl[controlValueKey] = result.payload;
controlValueToProblematicPlaceholders[controlValueKey] = result.problematicPlaceholders;
this.manipulateJsonValue(command.controlValues, controlValueKey, (value) =>
removeSurroundedWordsFromSentence(value, result.problematicPlaceholders)
);
}
aggregatedDefaultValues = _.merge(result.payload, aggregatedDefaultValues);
}

return aggregatedDefaultValues;
}
private manipulateJsonValue<T>(obj: T, key: string, manipulateFn: (value: string) => string): void {
// Split the key by '.' to handle nested properties
const keys = key.split('.');

// Use reduce to navigate to the nested property
const lastKey = keys.pop(); // Get the last key
const target = keys.reduce((acc, curr) => acc[curr], obj); // Navigate to the target object

// Check if the target and lastKey are valid
if (target && lastKey && typeof target[lastKey] === 'string') {
// Apply the manipulation function and update the value in place
target[lastKey] = manipulateFn(target[lastKey]);
} else {
throw new Error(`Invalid key: ${key} or the value at this key is not a string.`);
}
}

private hasNoValues(command: BuildDefaultPayloadCommand) {
return (
!command.controlValues ||
Expand All @@ -61,12 +142,15 @@ export class BuildDefaultPayloadUseCase {
private buildVariableMissingIssueRecord(
valueKeyToDefaultsMap: Record<string, Record<string, unknown>>,
aggregatedDefaultValues: Record<string, unknown>,
payloadValues: PreviewPayload | undefined
payloadValues: PreviewPayload | undefined,
controlValueToProblematicPlaceholders: Record<string, string[]>
) {
const defaultVariableToValueKeyMap = flattenJsonWithArrayValues(valueKeyToDefaultsMap);
const missingRequiredPayloadIssues = this.findMissingKeys(aggregatedDefaultValues, payloadValues);

return this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap);
const buildPayloadIssues = this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap);

return addVariableIssues(controlValueToProblematicPlaceholders, buildPayloadIssues);
}

private findMissingKeys(requiredRecord: Record<string, unknown>, actualRecord?: PreviewPayload) {
Expand Down Expand Up @@ -169,3 +253,16 @@ function getDotNotationKeys(input: NestedRecord, parentKey: string = '', keys: s

return keys;
}
function removeSurroundedWordsFromSentence(sentence: string, wordsToRemove: string[]): string {
// Create a set for faster lookup of words to remove
const wordsSet = new Set(wordsToRemove.map((word) => `{{${word}}}`));

// Split the sentence into an array of words
const wordsArray = sentence.split(' ');

// Filter out the words that are in the wordsToRemove set
const filteredWords = wordsArray.filter((word) => !wordsSet.has(word));

// Join the remaining words back into a string
return filteredWords.join(' ').trim(); // Trim to remove any leading/trailing whitespace
}
Loading

0 comments on commit 0a708b0

Please sign in to comment.