Skip to content

Commit

Permalink
Merge branch 'next' into add-workflow-actions-section
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy authored Jan 26, 2025
2 parents 77b4ac1 + a89ea3b commit 55134cc
Show file tree
Hide file tree
Showing 66 changed files with 1,240 additions and 1,329 deletions.
2 changes: 1 addition & 1 deletion .source
2 changes: 2 additions & 0 deletions apps/api/src/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,7 @@ PLAIN_IDENTITY_VERIFICATION_SECRET_KEY='PLAIN_IDENTITY_VERIFICATION_SECRET_KEY'
PLAIN_CARDS_HMAC_SECRET_KEY='PLAIN_CARDS_HMAC_SECRET_KEY'

NOVU_INTERNAL_SECRET_KEY=
NOVU_SECRET_KEY='NOVU_SECRET_KEY'

# expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). Eg: 60, "2 days", "10h", "7d"
SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME='15 days'
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,17 @@ describe('EmailOutputRendererUsecase', () => {
});

describe('conditional block transformation (showIfKey)', () => {
it('should render content when showIfKey condition is true', async () => {
describe('truthy conditions', () => {
const truthyValues = [
{ value: true, desc: 'boolean true' },
{ value: 1, desc: 'number 1' },
{ value: 'true', desc: 'string "true"' },
{ value: 'TRUE', desc: 'string "TRUE"' },
{ value: 'yes', desc: 'string "yes"' },
{ value: {}, desc: 'empty object' },
{ value: [], desc: 'empty array' },
];

const mockTipTapNode: MailyJSONContent = {
type: 'doc',
content: [
Expand Down Expand Up @@ -371,27 +381,40 @@ describe('EmailOutputRendererUsecase', () => {
],
};

const renderCommand = {
controlValues: {
subject: 'Conditional Test',
body: JSON.stringify(mockTipTapNode),
},
fullPayloadForRender: {
...mockFullPayload,
payload: {
isPremium: true,
},
},
};
truthyValues.forEach(({ value, desc }) => {
it(`should render content when showIfKey is ${desc}`, async () => {
const renderCommand = {
controlValues: {
subject: 'Conditional Test',
body: JSON.stringify(mockTipTapNode),
},
fullPayloadForRender: {
...mockFullPayload,
payload: {
isPremium: value,
},
},
};

const result = await emailOutputRendererUsecase.execute(renderCommand);
const result = await emailOutputRendererUsecase.execute(renderCommand);

expect(result.body).to.include('Before condition');
expect(result.body).to.include('Premium content');
expect(result.body).to.include('After condition');
expect(result.body).to.include('Before condition');
expect(result.body).to.include('Premium content');
expect(result.body).to.include('After condition');
});
});
});

it('should not render content when showIfKey condition is false', async () => {
describe('falsy conditions', () => {
const falsyValues = [
{ value: false, desc: 'boolean false' },
{ value: 0, desc: 'number 0' },
{ value: '', desc: 'empty string' },
{ value: null, desc: 'null' },
{ value: undefined, desc: 'undefined' },
{ value: 'UNDEFINED', desc: 'string "UNDEFINED"' },
];

const mockTipTapNode: MailyJSONContent = {
type: 'doc',
content: [
Expand Down Expand Up @@ -423,34 +446,33 @@ describe('EmailOutputRendererUsecase', () => {
type: 'text',
text: 'After condition',
},
{
type: 'text',
text: 'After condition 2',
},
],
},
],
};

const renderCommand = {
controlValues: {
subject: 'Conditional Test',
body: JSON.stringify(mockTipTapNode),
},
fullPayloadForRender: {
...mockFullPayload,
payload: {
isPremium: false,
},
},
};
falsyValues.forEach(({ value, desc }) => {
it(`should not render content when showIfKey is ${desc}`, async () => {
const renderCommand = {
controlValues: {
subject: 'Conditional Test',
body: JSON.stringify(mockTipTapNode),
},
fullPayloadForRender: {
...mockFullPayload,
payload: {
isPremium: value,
},
},
};

const result = await emailOutputRendererUsecase.execute(renderCommand);
const result = await emailOutputRendererUsecase.execute(renderCommand);

expect(result.body).to.include('Before condition');
expect(result.body).to.not.include('Premium content');
expect(result.body).to.include('After condition');
expect(result.body).to.include('After condition 2');
expect(result.body).to.include('Before condition');
expect(result.body).to.not.include('Premium content');
expect(result.body).to.include('After condition');
});
});
});

it('should handle nested conditional blocks correctly', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,15 @@ export class EmailOutputRendererUsecase {
});
}

private stringToBoolean(value: unknown): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
private stringToBoolean(value: string): boolean {
const normalized = value.toLowerCase().trim();
if (normalized === 'false' || normalized === 'null' || normalized === 'undefined') return false;

return false;
try {
return Boolean(JSON.parse(normalized));
} catch {
return Boolean(normalized);
}
}

private isVariableNode(
Expand Down
111 changes: 111 additions & 0 deletions apps/api/src/app/shared/services/query-parser/query-parser.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ type StringValidation =
isValid: false;
};

type BooleanValidation =
| {
isValid: true;
input: boolean;
}
| {
isValid: false;
};

function validateStringInput(dataInput: unknown, ruleValue: unknown): StringValidation {
if (typeof dataInput !== 'string' || typeof ruleValue !== 'string') {
return { isValid: false };
Expand All @@ -43,6 +52,45 @@ function validateRangeInput(dataInput: unknown, ruleValue: unknown): RangeValida
return { isValid: valid, min, max };
}

function validateBooleanInput(dataInput: unknown): BooleanValidation {
if (typeof dataInput !== 'boolean' && dataInput !== 'true' && dataInput !== 'false') {
return { isValid: false };
}

return { isValid: true, input: typeof dataInput === 'boolean' ? dataInput : dataInput === 'true' };
}

function validateComparison(
a: unknown,
b: unknown
): { isValid: true; a: number | string | boolean; b: number | string | boolean } | { isValid: false } {
// handle boolean values and string representations of booleans
const booleanA = validateBooleanInput(a);
const booleanB = validateBooleanInput(b);
if (booleanA.isValid && booleanB.isValid) {
return { isValid: true, a: booleanA.input, b: booleanB.input };
}

// try to convert to numbers if possible
const numA = Number(a);
const numB = Number(b);
if (!Number.isNaN(numA) && !Number.isNaN(numB)) {
return { isValid: true, a: numA, b: numB };
}

// handle dates
if (typeof a === 'string' && typeof b === 'string') {
const dateA = new Date(a);
const dateB = new Date(b);

if (!Number.isNaN(dateA.getTime()) && !Number.isNaN(dateB.getTime())) {
return { isValid: true, a: dateA.getTime(), b: dateB.getTime() };
}
}

return { isValid: false };
}

function createStringOperator(evaluator: (input: string, value: string) => boolean) {
return (dataInput: unknown, ruleValue: unknown): boolean => {
const validation = validateStringInput(dataInput, ruleValue);
Expand Down Expand Up @@ -117,6 +165,69 @@ const initializeCustomOperators = (): void => {

return dataInput < validation.min || dataInput > validation.max;
});

jsonLogic.rm_operation('<');
jsonLogic.add_operation('<', (a: unknown, b: unknown) => {
const validation = validateComparison(a, b);
if (!validation.isValid) return false;

return validation.a < validation.b;
});

jsonLogic.rm_operation('>');
jsonLogic.add_operation('>', (a: unknown, b: unknown) => {
const validation = validateComparison(a, b);
if (!validation.isValid) return false;

return validation.a > validation.b;
});

jsonLogic.rm_operation('<=');
jsonLogic.add_operation('<=', (first: unknown, second: unknown, third?: unknown) => {
// handle three argument case (typically used in between operations)
if (third !== undefined) {
const validation1 = validateComparison(first, second);
const validation2 = validateComparison(second, third);
if (!validation1.isValid || !validation2.isValid) return false;

return validation1.a <= validation1.b && validation1.b <= validation2.b;
}

const validation = validateComparison(first, second);
if (!validation.isValid) return false;

return validation.a <= validation.b;
});

jsonLogic.rm_operation('>=');
jsonLogic.add_operation('>=', (a: unknown, b: unknown) => {
const validation = validateComparison(a, b);
if (!validation.isValid) return false;

return validation.a >= validation.b;
});

jsonLogic.rm_operation('==');
jsonLogic.add_operation('==', (a: unknown, b: unknown) => {
const validation = validateComparison(a, b);
if (!validation.isValid) {
// fall back to strict equality for other types
return a === b;
}

return validation.a === validation.b;
});

jsonLogic.rm_operation('!=');
jsonLogic.add_operation('!=', (a: unknown, b: unknown) => {
const validation = validateComparison(a, b);
if (!validation.isValid) {
// fall back to strict inequality for other types
return a !== b;
}

return validation.a !== validation.b;
});
};

initializeCustomOperators();
Expand Down
7 changes: 1 addition & 6 deletions apps/api/src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,7 @@ const PROVIDERS = [
];

const IMPORTS = [
QueuesModule.forRoot([
JobTopicNameEnum.EXECUTION_LOG,
JobTopicNameEnum.WEB_SOCKETS,
JobTopicNameEnum.WORKFLOW,
JobTopicNameEnum.INBOUND_PARSE_MAIL,
]),
QueuesModule.forRoot([JobTopicNameEnum.WEB_SOCKETS, JobTopicNameEnum.WORKFLOW, JobTopicNameEnum.INBOUND_PARSE_MAIL]),
LoggerModule.forRoot(
createNestLoggingModuleOptions({
serviceName: packageJson.name,
Expand Down
28 changes: 25 additions & 3 deletions apps/api/src/app/support/usecases/plain-cards.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ export class PlainCardsUsecase {
private userRepository: UserRepository
) {}
async fetchCustomerDetails(command: PlainCardsCommand) {
const key = process.env.NOVU_REGION === 'eu-west-2' ? 'customer-details-eu' : 'customer-details-us';

const key = `customer-details-${process.env.NOVU_REGION}`;
if (!command?.customer?.externalId) {
return {
data: {},
Expand All @@ -51,7 +50,7 @@ export class PlainCardsUsecase {
},
{
componentText: {
text: 'This user is not yet registered on Novu',
text: 'This user is not yet registered in this region',
},
},
],
Expand All @@ -61,6 +60,29 @@ export class PlainCardsUsecase {
}

const organizations = await this.organizationRepository.findUserActiveOrganizations(command?.customer?.externalId);
if (!organizations) {
return {
data: {},
cards: [
{
key,
components: [
{
componentSpacer: {
spacerSize: 'S',
},
},
{
componentText: {
text: 'This user is not yet registered in this region',
},
},
],
},
],
};
}

const sessions = await this.userRepository.findUserSessions(command?.customer?.externalId);

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,10 @@ export class BuildStepIssuesUsecase {
issues.controls = issues.controls || {};

issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => {
const message = error.message
? error.message[0].toUpperCase() + error.message.slice(1).split(' line:')[0]
: '';
const message = error.message ? error.message.split(' line:')[0] : '';

return {
message: `${message} variable: ${error.output}`,
message: `Variable ${error.output} ${message}`.trim(),
issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE,
variableName: error.output,
};
Expand Down Expand Up @@ -271,7 +269,7 @@ export class BuildStepIssuesUsecase {
error.message?.includes('mailto') &&
error.message?.includes('https')
) {
return `Invalid URL format. Must be a valid absolute URL, path starting with /, or {{variable}}`;
return `Invalid URL. Must be a valid full URL, path starting with /, or {{variable}}`;
}

return error.message || 'Invalid value';
Expand Down
Loading

0 comments on commit 55134cc

Please sign in to comment.