Repository: novuhq/novu
Pull request #8178: feat(api-service,framework): validate liquidjs filter args for the variables
Author: login: LetItRock
Labels: name: @novu/shared
name: @novu/dashboard
name: @novu/api-service
Created: 2025-04-22T16:15:06Z | Merged: 2025-04-23T15:34:46Z

### What changed? Why was the change needed?

Implemented the validation logic for the liquid variable filters, which is built on top of the step issues. 
The filter args validation function is defined close to the LiquidJS filter, meaning that in the Framework. We can later decide if we want to do similar validation in the Framework.


### Screenshots

![Screenshot 2025-04-22 at 18 02 31](https://github.com/user-attachments/assets/f3a5f487-cf77-4269-b9e6-73e961e3c4b2)


https://github.com/user-attachments/assets/551c844b-c51b-461a-907e-8e8a68a7e283





Diff:
diff --git a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
index 087b4d3dc14..1997ecaf48c 100644
--- a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
+++ b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
@@ -146,13 +146,20 @@ export class BuildStepIssuesUsecase {
         issues.controls = issues.controls || {};
 
         // eslint-disable-next-line no-param-reassign
-        issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => {
-          const message = error.message ? error.message.split(' line:')[0] : '';
+        issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((invalidVariable) => {
+          const message = invalidVariable.message ? invalidVariable.message.split(' line:')[0] : '';
+          if ('filterMessage' in invalidVariable) {
+            return {
+              message: `Filter ${invalidVariable.filterMessage} in ${invalidVariable.name}`,
+              issueType: StepContentIssueEnum.INVALID_FILTER_ARG_IN_VARIABLE,
+              variableName: invalidVariable.output,
+            };
+          }
 
           return {
-            message: `Variable ${error.output} ${message}`.trim(),
+            message: `Variable ${invalidVariable.output} ${message}`.trim(),
             issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE,
-            variableName: error.output,
+            variableName: invalidVariable.output,
           };
         });
       } else {
diff --git a/apps/api/src/app/workflows-v2/util/build-variables.ts b/apps/api/src/app/workflows-v2/util/build-variables.ts
index 24b3f3c58eb..ce4897f0706 100644
--- a/apps/api/src/app/workflows-v2/util/build-variables.ts
+++ b/apps/api/src/app/workflows-v2/util/build-variables.ts
@@ -2,7 +2,7 @@ import _ from 'lodash';
 import { AdditionalOperation, RulesLogic } from 'json-logic-js';
 import { PinoLogger } from '@novu/application-generic';
 
-import { extractLiquidTemplateVariables, TemplateVariables, Variable } from './template-parser/liquid-parser';
+import { extractLiquidTemplateVariables, TemplateVariables } from './template-parser/liquid-parser';
 import { WrapMailyInLiquidUseCase } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase';
 import { isStringifiedMailyJSONContent } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command';
 import { extractFieldsFromRules, isValidRule } from '../../shared/services/query-parser/query-parser.service';
@@ -41,100 +41,13 @@ export function buildVariables(
     };
   }
 
-  const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue));
-
-  // don't compare against schema if it's not provided
-  if (!variableSchema) {
-    return { validVariables, invalidVariables };
-  }
-
-  const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables(
+  const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
+    template: JSON.stringify(variableControlValue),
     variableSchema,
-    validVariables
-  );
+  });
 
   return {
-    validVariables: validSchemaVariables,
-    invalidVariables: [...invalidVariables, ...invalidSchemaVariables],
+    validVariables,
+    invalidVariables,
   };
 }
-
-function isPropertyAllowed(schema: JSONSchemaDto, propertyPath: string) {
-  let currentSchema = { ...schema };
-  if (!currentSchema || typeof currentSchema !== 'object') {
-    return false;
-  }
-
-  const pathParts = propertyPath
-    .split('.')
-    .map((part) => {
-      // Split array notation into [propName, index]
-      const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
-
-      return arrayMatch ? [arrayMatch[1], arrayMatch[2]] : [part];
-    })
-    .flat();
-
-  for (const part of pathParts) {
-    const { properties, additionalProperties, type } = currentSchema;
-
-    // Handle direct property access
-    if (properties?.[part]) {
-      currentSchema = properties[part];
-      continue;
-    }
-
-    // Handle array paths - valid if schema is array type
-    if (type === 'array') {
-      // Valid array index or property access
-      const isArrayIndex = !Number.isNaN(Number(part)) && Number(part) >= 0;
-      const arrayItemSchema = currentSchema.items as Record<string, unknown>;
-
-      if (isArrayIndex) {
-        currentSchema = arrayItemSchema;
-        continue;
-      }
-
-      if (arrayItemSchema?.properties?.[part]) {
-        currentSchema = arrayItemSchema.properties[part];
-        continue;
-      }
-    }
-
-    if (additionalProperties === true) {
-      return true;
-    }
-
-    return false;
-  }
-
-  return true;
-}
-
-function createInvalidVariable(variable: Variable): Variable {
-  return {
-    name: variable.output,
-    context: variable.context,
-    message: 'Variable is not supported',
-    output: variable.output,
-  };
-}
-
-function identifyUnknownVariables(variableSchema: JSONSchemaDto, validVariables: Variable[]): TemplateVariables {
-  const variables = _.cloneDeep(validVariables);
-
-  return variables.reduce<TemplateVariables>(
-    (acc, variable) => {
-      const isValid = isPropertyAllowed(variableSchema, variable.name);
-
-      if (isValid) {
-        acc.validVariables.push(variable);
-      } else {
-        acc.invalidVariables.push(createInvalidVariable(variable));
-      }
-
-      return acc;
-    },
-    { validVariables: [], invalidVariables: [] }
-  );
-}
diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts
index 41838a4adbc..4d128f2a907 100644
--- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts
+++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts
@@ -4,7 +4,7 @@ import { extractLiquidTemplateVariables } from './liquid-parser';
 describe('parseLiquidVariables', () => {
   it('should not extract variable without namespace', () => {
     const template = '{{name}} {{age}}';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members([]);
@@ -15,7 +15,7 @@ describe('parseLiquidVariables', () => {
 
   it('should extract nested object paths', () => {
     const template = 'Hello {{user.profile.name}}, your address is {{user.address.street}}';
-    const { validVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['user.profile.name', 'user.address.street']);
@@ -23,7 +23,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle multiple occurrences of the same variable', () => {
     const template = '{{user.name}} {{user.name}} {{user.name}} {{invalid..foo}} {{invalid..foo}}';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['user.name']);
@@ -33,7 +33,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle mixed content with HTML and variables', () => {
     const template = '<div>Hello {{user.name}}</div><span>{{status}}</span>';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['user.name']);
@@ -43,7 +43,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle whitespace in template syntax', () => {
     const template = '{{ user.name          }}';
-    const { validVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['user.name']);
@@ -51,7 +51,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle empty template string', () => {
     const template = '';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
 
     expect(validVariables).to.have.lengthOf(0);
     expect(invalidVariables).to.have.lengthOf(0);
@@ -59,7 +59,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle template with no variables', () => {
     const template = 'Hello World!';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
 
     expect(validVariables).to.have.lengthOf(0);
     expect(invalidVariables).to.have.lengthOf(0);
@@ -67,7 +67,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle special characters in variable names', () => {
     const template = '{{subscriber.special_var_1}} {{subscriber.data-point}}';
-    const { validVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['subscriber.special_var_1', 'subscriber.data-point']);
@@ -75,7 +75,7 @@ describe('parseLiquidVariables', () => {
 
   it('should handle whitespace in between template syntax', () => {
     const template = '{{ user. name }}';
-    const { validVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables } = extractLiquidTemplateVariables({ template });
 
     expect(validVariables).to.have.lengthOf(1);
     expect(validVariables[0].name).to.equal('user.name');
@@ -83,7 +83,7 @@ describe('parseLiquidVariables', () => {
 
   it('should extract array variables from for loop syntax', () => {
     const template = '{{ payload.comments[0].author }} {{ payload.comments[1].author }}';
-    const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template);
+    const { validVariables, invalidVariables } = extractLiquidTemplateVariables({ template });
     const validVariablesNames = validVariables.map((variable) => variable.name);
 
     expect(validVariablesNames).to.have.members(['payload.comments[0].author', 'payload.comments[1].author']);
@@ -92,9 +92,9 @@ describe('parseLiquidVariables', () => {
 
   describe('Error handling', () => {
     it('should handle invalid liquid syntax gracefully', () => {
-      const { validVariables, invalidVariables } = extractLiquidTemplateVariables(
-        '{{invalid..syntax}} {{invalid2..syntax}}'
-      );
+      const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
+        template: '{{invalid..syntax}} {{invalid2..syntax}}',
+      });
 
       expect(validVariables).to.have.lengthOf(0);
       expect(invalidVariables).to.have.lengthOf(2);
@@ -104,9 +104,9 @@ describe('parseLiquidVariables', () => {
     });
 
     it('should handle invalid liquid syntax gracefully, return valid variables', () => {
-      const { validVariables, invalidVariables } = extractLiquidTemplateVariables(
-        '{{subscriber.name}} {{invalid..syntax}}'
-      );
+      const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
+        template: '{{subscriber.name}} {{invalid..syntax}}',
+      });
       const validVariablesNames = validVariables.map((variable) => variable.name);
 
       expect(validVariablesNames).to.have.members(['subscriber.name']);
@@ -115,16 +115,16 @@ describe('parseLiquidVariables', () => {
     });
 
     it('should handle undefined input gracefully', () => {
-      expect(() => extractLiquidTemplateVariables(undefined as any)).to.not.throw();
-      const { validVariables } = extractLiquidTemplateVariables(undefined as any);
+      expect(() => extractLiquidTemplateVariables({ template: undefined as any })).to.not.throw();
+      const { validVariables } = extractLiquidTemplateVariables({ template: undefined as any });
       const validVariablesNames = validVariables.map((variable) => variable.name);
 
       expect(validVariablesNames).to.have.lengthOf(0);
     });
 
     it('should handle non-string input gracefully', () => {
-      expect(() => extractLiquidTemplateVariables({} as any)).to.not.throw();
-      const { validVariables } = extractLiquidTemplateVariables({} as any);
+      expect(() => extractLiquidTemplateVariables({ template: {} as any })).to.not.throw();
+      const { validVariables } = extractLiquidTemplateVariables({ template: {} as any });
       const validVariablesNames = validVariables.map((variable) => variable.name);
 
       expect(validVariablesNames).to.have.lengthOf(0);
diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
index ac5342eb774..eaa8355fd62 100644
--- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
+++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
@@ -1,4 +1,6 @@
-import { Liquid, LiquidError, RenderError, Template } from 'liquidjs';
+import { Filter, Liquid, LiquidError, Output, RenderError, Template, TokenKind } from 'liquidjs';
+import { FILTER_VALIDATORS, LiquidFilterIssue } from '@novu/framework/internal';
+import { JSONSchemaDto } from '../../dtos';
 import { extractLiquidExpressions, isValidTemplate } from './parser-utils';
 
 const LIQUID_CONFIG = {
@@ -33,13 +35,16 @@ export type Variable = {
   /** Error message if the variable is invalid */
   message?: string;
 
+  /** Error message if the variable filter is invalid */
+  filterMessage?: string;
+
   /** The full liquid output string (e.g. "{{user.name | upcase}}") */
   output: string;
 };
 
 export type TemplateVariables = {
-  validVariables: Variable[];
-  invalidVariables: Variable[];
+  validVariables: Array<Variable>;
+  invalidVariables: Array<Variable>;
 };
 
 /**
@@ -82,9 +87,16 @@ function isLiquidErrors(error: unknown): error is LiquidErrors {
  * }
  *
  * @param template - The Liquid template string to parse
+ * @param variableSchema - The schema to validate the variables against
  * @returns Object containing arrays of valid and invalid variables found in the template
  */
-export function extractLiquidTemplateVariables(template: string): TemplateVariables {
+export function extractLiquidTemplateVariables({
+  template,
+  variableSchema,
+}: {
+  template: string;
+  variableSchema?: JSONSchemaDto;
+}): TemplateVariables {
   if (!isValidTemplate(template)) {
     return { validVariables: [], invalidVariables: [] };
   }
@@ -94,12 +106,18 @@ export function extractLiquidTemplateVariables(template: string): TemplateVariab
     return { validVariables: [], invalidVariables: [] };
   }
 
-  return processLiquidRawOutput(liquidRawOutput);
+  return processLiquidRawOutput({ rawOutputs: liquidRawOutput, variableSchema });
 }
 
-function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
-  const validVariables: Variable[] = [];
-  const invalidVariables: Variable[] = [];
+function processLiquidRawOutput({
+  rawOutputs,
+  variableSchema,
+}: {
+  rawOutputs: string[];
+  variableSchema?: JSONSchemaDto;
+}): TemplateVariables {
+  const validVariables: Array<Variable> = [];
+  const invalidVariables: Array<Variable> = [];
   const processedVariables = new Set<string>();
 
   function addVariable(variable: Variable, isValid: boolean) {
@@ -111,7 +129,7 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
 
   for (const rawOutput of rawOutputs) {
     try {
-      const result = parseByLiquid(rawOutput);
+      const result = parseByLiquid({ rawOutput, variableSchema });
       result.validVariables.forEach((variable) => addVariable(variable, true));
       result.invalidVariables.forEach((variable) => addVariable(variable, false));
     } catch (error: unknown) {
@@ -134,39 +152,134 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
   return { validVariables, invalidVariables };
 }
 
-function parseByLiquid(rawOutput: string): TemplateVariables {
-  const validVariables: Variable[] = [];
-  const invalidVariables: Variable[] = [];
-  const parsed = parserEngine.parse(rawOutput) as unknown as Template[];
+function isPropertyAllowed(schema: JSONSchemaDto | undefined, propertyPath: string) {
+  if (!schema) {
+    return true;
+  }
 
-  parsed.forEach((template: Template) => {
-    if (isOutputToken(template)) {
-      const result = extractProps(template);
+  let currentSchema = { ...schema };
+  if (typeof currentSchema !== 'object') {
+    return false;
+  }
+
+  const pathParts = propertyPath
+    .split('.')
+    .map((part) => {
+      // Split array notation into [propName, index]
+      const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
 
-      if (result.valid && result.props.length > 0) {
-        const path = result.props.reduce((acc, prop, i) => {
-          // if the prop is a number, preserve array notation (.[idx])
-          if (typeof prop === 'number') {
-            return `${acc}[${prop}]`;
-          }
+      return arrayMatch ? [arrayMatch[1], arrayMatch[2]] : [part];
+    })
+    .flat();
 
-          return i === 0 ? prop : `${acc}.${prop}`;
-        }, '');
+  for (const part of pathParts) {
+    const { properties, additionalProperties, type } = currentSchema;
 
-        validVariables.push({ name: path, output: rawOutput });
+    // Handle direct property access
+    if (properties?.[part]) {
+      currentSchema = properties[part] as JSONSchemaDto;
+      continue;
+    }
+
+    // Handle array paths - valid if schema is array type
+    if (type === 'array') {
+      // Valid array index or property access
+      const isArrayIndex = !Number.isNaN(Number(part)) && Number(part) >= 0;
+      const arrayItemSchema = currentSchema.items as Record<string, unknown>;
+
+      if (isArrayIndex) {
+        currentSchema = arrayItemSchema;
+        continue;
       }
 
-      if (!result.valid) {
-        invalidVariables.push({ name: template?.token?.input, message: result.error, output: rawOutput });
+      if (arrayItemSchema?.properties?.[part]) {
+        currentSchema = arrayItemSchema.properties[part];
+        continue;
       }
     }
-  });
+
+    if (additionalProperties === true) {
+      return true;
+    }
+
+    return false;
+  }
+
+  return true;
+}
+
+function parseByLiquid({
+  rawOutput,
+  variableSchema,
+}: {
+  rawOutput: string;
+  variableSchema?: JSONSchemaDto;
+}): TemplateVariables {
+  const validVariables: Array<Variable> = [];
+  const invalidVariables: Array<Variable> = [];
+  const parsed = parserEngine.parse(rawOutput);
+
+  parsed
+    .filter((template: Template) => isOutputToken(template))
+    .forEach((template: Template) => {
+      const result = extractProps(template);
+      const variableName = buildVariable(result.props);
+      const isDigestEventsVariable = !!variableName.match(/^steps\..+\.events$/);
+      const filters = extractFilters(template);
+      const filterIssues = validateFilters(filters, isDigestEventsVariable);
+      const hasValidFilters = filterIssues.length === 0;
+
+      if (!hasValidFilters) {
+        const token = template?.token;
+        invalidVariables.push({
+          name: token?.input,
+          filterMessage: filterIssues[0].message,
+          output: rawOutput,
+        });
+
+        return;
+      }
+
+      const isValidVariable = result.valid && result.props.length > 0;
+      if (isValidVariable) {
+        const isAllowedVariable = isPropertyAllowed(variableSchema, variableName);
+        if (isAllowedVariable) {
+          validVariables.push({ name: variableName, output: rawOutput });
+        } else {
+          invalidVariables.push({
+            name: variableName,
+            message: 'is not supported',
+            output: rawOutput,
+          });
+        }
+      }
+
+      if (!result.valid) {
+        const token = template?.token;
+        invalidVariables.push({
+          name: token?.input,
+          message: result.error,
+          output: rawOutput,
+        });
+      }
+    });
 
   return { validVariables, invalidVariables };
 }
 
+const buildVariable = (parts: string[]) => {
+  return parts.reduce((acc, prop, i) => {
+    // if the prop is a number, preserve array notation (.[idx])
+    if (typeof prop === 'number') {
+      return `${acc}[${prop}]`;
+    }
+
+    return i === 0 ? prop : `${acc}.${prop}`;
+  }, '');
+};
+
 function isOutputToken(template: Template): boolean {
-  return template.token?.constructor.name === 'OutputToken';
+  return template.token?.kind === TokenKind.Output;
 }
 
 function extractProps(template: any): { valid: boolean; props: string[]; error?: string } {
@@ -213,3 +326,27 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?:
 
   return { valid: true, props: validProps };
 }
+
+function extractFilters(template: Template): Filter[] {
+  if (template instanceof Output) {
+    return template.value.filters;
+  }
+
+  return [];
+}
+
+function validateFilters(filters: Filter[], isDigestEventsVariable: boolean): LiquidFilterIssue[] {
+  return filters.reduce((acc, filter) => {
+    const validator = FILTER_VALIDATORS[filter.name];
+    if (!validator) return acc;
+
+    let args: unknown[] = [...filter.args];
+    if (filter.name === 'toSentence') {
+      args = [{ requireKeyPath: isDigestEventsVariable }, ...filter.args];
+    }
+
+    const filterIssues = validator(...args);
+
+    return [...acc, ...filterIssues];
+  }, []);
+}
diff --git a/apps/dashboard/src/components/primitives/tabs.tsx b/apps/dashboard/src/components/primitives/tabs.tsx
index 681c53de828..a07f61323fe 100644
--- a/apps/dashboard/src/components/primitives/tabs.tsx
+++ b/apps/dashboard/src/components/primitives/tabs.tsx
@@ -76,7 +76,7 @@ const TabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, T
 TabsList.displayName = TabsPrimitive.List.displayName;
 
 const tabsTriggerVariants = cva(
-  'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-all text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 px-1',
+  'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-all text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed px-1',
   {
     variants: {
       variant: {
diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx
index 848be0f7a96..cd4afbaa357 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx
@@ -10,7 +10,7 @@ import { ChatEditorPreview } from '@/components/workflow-editor/steps/chat/chat-
 import { useEditorPreview } from '../use-editor-preview';
 
 export const ChatTabs = (props: StepEditorProps) => {
-  const { workflow, step } = props;
+  const { workflow, step, hasStepValidationErrors } = props;
   const { dataSchema, uiSchema } = step.controls;
   const [tabsValue, setTabsValue] = useState('editor');
   const form = useFormContext();
@@ -47,6 +47,7 @@ export const ChatTabs = (props: StepEditorProps) => {
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
       previewStep={previewStep}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
index b945ce7de5e..699a8922845 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
@@ -29,11 +29,12 @@ const STEP_TYPE_TO_TEMPLATE_FORM: Record<StepTypeEnum, (args: StepEditorProps) =
 };
 
 export type StepEditorProps = {
+  hasStepValidationErrors: boolean;
   workflow: WorkflowResponseDto;
   step: StepResponseDto;
 };
 
-type ConfigureStepTemplateFormProps = StepEditorProps & {
+type ConfigureStepTemplateFormProps = Omit<StepEditorProps, 'hasStepValidationErrors'> & {
   update: UpdateWorkflowFn;
 };
 
@@ -97,11 +98,16 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps)
 
   const value = useMemo(() => ({ saveForm }), [saveForm]);
 
+  const hasStepValidationErrors = useMemo(() => {
+    const stepIssues = flattenIssues(step.issues?.controls);
+    return hideValidationErrorsOnFirstRender ? false : Object.keys(stepIssues).length > 0;
+  }, [step.issues?.controls, hideValidationErrorsOnFirstRender]);
+
   return (
     <Form {...form}>
       <FormRoot className="flex h-full flex-col" onBlur={onBlur}>
         <SaveFormContext.Provider value={value}>
-          <TemplateForm workflow={workflow} step={step} />
+          <TemplateForm workflow={workflow} step={step} hasStepValidationErrors={hasStepValidationErrors} />
         </SaveFormContext.Provider>
       </FormRoot>
     </Form>
diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx
index 006931b4ae0..973057543b5 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx
@@ -9,7 +9,7 @@ import { CustomStepControls } from '../controls/custom-step-controls';
 import { useEditorPreview } from '../use-editor-preview';
 
 export const EmailTabs = (props: StepEditorProps) => {
-  const { workflow, step } = props;
+  const { workflow, step, hasStepValidationErrors } = props;
   const { dataSchema, uiSchema } = step.controls;
   const form = useFormContext();
   const [tabsValue, setTabsValue] = useState('editor');
@@ -47,6 +47,7 @@ export const EmailTabs = (props: StepEditorProps) => {
       previewContent={previewContent}
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx
index fc35a1f620d..b51a37422e5 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx
@@ -9,7 +9,7 @@ import { CustomStepControls } from '../controls/custom-step-controls';
 import { useEditorPreview } from '../use-editor-preview';
 
 export const InAppTabs = (props: StepEditorProps) => {
-  const { workflow, step } = props;
+  const { workflow, step, hasStepValidationErrors } = props;
   const { dataSchema, uiSchema } = step.controls;
   const form = useFormContext();
   const [tabsValue, setTabsValue] = useState('editor');
@@ -47,6 +47,7 @@ export const InAppTabs = (props: StepEditorProps) => {
       previewContent={previewContent}
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx
index 6aeb61af3d6..535d5617e35 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx
@@ -3,7 +3,7 @@ import { CustomStepControls } from './controls/custom-step-controls';
 import { TemplateTabs } from './template-tabs';
 import type { StepEditorProps } from './configure-step-template-form';
 
-export const OtherStepTabs = ({ workflow, step }: StepEditorProps) => {
+export const OtherStepTabs = ({ workflow, step, hasStepValidationErrors }: StepEditorProps) => {
   const { dataSchema } = step.controls;
   const [tabsValue, setTabsValue] = useState('editor');
 
@@ -21,6 +21,7 @@ export const OtherStepTabs = ({ workflow, step }: StepEditorProps) => {
       previewContent={previewContent}
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx
index 23a725daaaf..e2093429c80 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx
@@ -9,7 +9,7 @@ import { useFormContext } from 'react-hook-form';
 import { useEditorPreview } from '../use-editor-preview';
 
 export const PushTabs = (props: StepEditorProps) => {
-  const { workflow, step } = props;
+  const { workflow, step, hasStepValidationErrors } = props;
   const { dataSchema, uiSchema } = step.controls;
   const [tabsValue, setTabsValue] = useState('editor');
   const form = useFormContext();
@@ -46,6 +46,7 @@ export const PushTabs = (props: StepEditorProps) => {
       previewContent={previewContent}
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx
index dc32dd5730c..ca276880468 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx
@@ -9,7 +9,7 @@ import { useFormContext } from 'react-hook-form';
 import { useEditorPreview } from '../use-editor-preview';
 
 export const SmsTabs = (props: StepEditorProps) => {
-  const { workflow, step } = props;
+  const { workflow, step, hasStepValidationErrors } = props;
   const { dataSchema, uiSchema } = step.controls;
   const form = useFormContext();
   const [tabsValue, setTabsValue] = useState('editor');
@@ -47,6 +47,7 @@ export const SmsTabs = (props: StepEditorProps) => {
       tabsValue={tabsValue}
       onTabChange={setTabsValue}
       previewStep={previewStep}
+      hasStepValidationErrors={hasStepValidationErrors}
     />
   );
 };
diff --git a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx
index f50ddc43172..e9a99b77117 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx
@@ -6,6 +6,7 @@ import { Notification5Fill } from '@/components/icons';
 import { Separator } from '@/components/primitives/separator';
 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs';
 import { CompactButton } from '../../primitives/button-compact';
+import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from '@/components/primitives/tooltip';
 
 interface TemplateTabsProps {
   editorContent: React.ReactNode;
@@ -13,6 +14,7 @@ interface TemplateTabsProps {
   tabsValue: string;
   onTabChange: (tab: string) => void;
   previewStep?: () => void;
+  hasStepValidationErrors?: boolean;
 }
 
 export const TemplateTabs = ({
@@ -21,6 +23,7 @@ export const TemplateTabs = ({
   tabsValue,
   onTabChange,
   previewStep,
+  hasStepValidationErrors = false,
 }: TemplateTabsProps) => {
   const navigate = useNavigate();
 
@@ -44,9 +47,24 @@ export const TemplateTabs = ({
             <RiPencilRuler2Line className="size-5 p-0.5" />
             <span>Editor</span>
           </TabsTrigger>
-          <TabsTrigger value="preview" className="gap-1.5" disabled={!previewContent}>
-            <Notification5Fill className="size-5 p-0.5" />
-            <span>Preview</span>
+          <TabsTrigger value="preview" className="gap-1.5" disabled={!previewContent || hasStepValidationErrors}>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <div className="flex gap-1.5">
+                  <Notification5Fill className="size-5 p-0.5" />
+                  <span>Preview</span>
+                </div>
+              </TooltipTrigger>
+              <TooltipPortal>
+                {hasStepValidationErrors && (
+                  <TooltipContent className="flex min-w-min max-w-[200px]" side="bottom">
+                    <span className="text-xs font-normal">
+                      Preview is disabled due to validation issues that need to be addressed
+                    </span>
+                  </TooltipContent>
+                )}
+              </TooltipPortal>
+            </Tooltip>
           </TabsTrigger>
         </TabsList>
 
diff --git a/packages/framework/src/filters/index.ts b/packages/framework/src/filters/index.ts
index 3064beb671c..3950b754526 100644
--- a/packages/framework/src/filters/index.ts
+++ b/packages/framework/src/filters/index.ts
@@ -1,3 +1,5 @@
 export * from './pluralize';
 export * from './to-sentence';
 export * from './digest';
+export * from './types';
+export * from './validators';
diff --git a/packages/framework/src/filters/to-sentence.ts b/packages/framework/src/filters/to-sentence.ts
index 331c9819151..47cd498d10f 100644
--- a/packages/framework/src/filters/to-sentence.ts
+++ b/packages/framework/src/filters/to-sentence.ts
@@ -1,6 +1,26 @@
+import { Filter, TokenKind } from 'liquidjs';
+import { z } from 'zod';
 import pluralize from 'pluralize';
 
+import { NumberToken, QuotedToken } from 'liquidjs/dist/tokens';
 import { getNestedValue } from '../utils/object.utils';
+import { LiquidFilterIssue } from './types';
+
+const DEFAULT_KEY_PATH = '';
+const DEFAULT_LIMIT = 2;
+const DEFAULT_OVERFLOW_SUFFIX = 'other';
+const DEFAULT_WORDS_CONNECTOR = ', ';
+const DEFAULT_TWO_WORDS_CONNECTOR = ' and ';
+const DEFAULT_LAST_WORD_CONNECTOR = ', and ';
+
+const ARG_INDEX_TO_ARG_NAME: Record<number, string> = {
+  0: 'keyPath',
+  1: 'limit',
+  2: 'overflowSuffix',
+  3: 'wordsConnector',
+  4: 'twoWordsConnector',
+  5: 'lastWordConnector',
+};
 
 /**
  * Format a list of items for digest notifications with configurable behavior
@@ -21,12 +41,12 @@ import { getNestedValue } from '../utils/object.utils';
  */
 export function toSentence(
   array: unknown,
-  keyPath: string = '',
-  limit = 2,
-  overflowSuffix = 'other',
-  wordsConnector = ', ',
-  twoWordsConnector = ' and ',
-  lastWordConnector = ', and '
+  keyPath = DEFAULT_KEY_PATH,
+  limit = DEFAULT_LIMIT,
+  overflowSuffix = DEFAULT_OVERFLOW_SUFFIX,
+  wordsConnector = DEFAULT_WORDS_CONNECTOR,
+  twoWordsConnector = DEFAULT_TWO_WORDS_CONNECTOR,
+  lastWordConnector = DEFAULT_LAST_WORD_CONNECTOR
 ): string {
   if (!Array.isArray(array) || array.length === 0) return '';
 
@@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
+    issues.push({
+      message: 'Expected at least 1 argument',
+      begin: 0,
+      end: 0,
+      value: '',
+    });
+
+    return issues;
+  }
+
+  const argsSchema = z.object({
+    keyPath: requireKeyPath ? z.string().min(1, 'must be non-empty') : z.string().optional().default(DEFAULT_KEY_PATH),
+    limit: z
+      .number()
+      .optional()
+      .default(DEFAULT_LIMIT)
+      .refine((val) => {
+        return val >= 0;
+      }, 'must be greater than or equal to 0'),
+    overflowSuffix: z.string().optional().default(DEFAULT_OVERFLOW_SUFFIX),
+    wordsConnector: z.string().optional().default(DEFAULT_WORDS_CONNECTOR),
+    twoWordsConnector: z.string().optional().default(DEFAULT_TWO_WORDS_CONNECTOR),
+    lastWordConnector: z.string().optional().default(DEFAULT_LAST_WORD_CONNECTOR),
+  });
+
+  const argsObject: Record<string, number | string> = {};
+  args.forEach((arg, index) => {
+    if (!Array.isArray(arg)) {
+      let value: string | number = arg.getText();
+      if (arg.kind === TokenKind.Quoted) {
+        value = (arg as QuotedToken).content;
+      } else if (arg.kind === TokenKind.Number) {
+        value = (arg as NumberToken).content;
+      }
+      const argName = ARG_INDEX_TO_ARG_NAME[index];
+      argsObject[argName] = value;
+    }
+  });
+
+  const result = argsSchema.safeParse(argsObject);
+
+  if (!result.success) {
+    for (const error of result.error.issues) {
+      let type = 'string';
+      if ('type' in error) {
+        type = error.type;
+      }
+
+      const path = error.path[0];
+      const argIndexToArgName = Object.entries(ARG_INDEX_TO_ARG_NAME).find(([_, argName]) => argName === path);
+      const argIndex = argIndexToArgName ? parseInt(argIndexToArgName[0], 10) : null;
+      const token = typeof argIndex === 'number' ? args[argIndex] : null;
+
+      if (token && !Array.isArray(token)) {
+        issues.push({
+          message: `"toSentence" expects a ${type}${error.message ? ` that ${error.message}` : ''} for argument "${path}"`,
+          begin: token.begin,
+          end: token.end,
+          value: token.getText(),
+        });
+      }
+    }
+  }
+
+  return issues;
+}
diff --git a/packages/framework/src/filters/types.ts b/packages/framework/src/filters/types.ts
new file mode 100644
index 00000000000..94006aaa970
--- /dev/null
+++ b/packages/framework/src/filters/types.ts
@@ -0,0 +1,6 @@
+export type LiquidFilterIssue = {
+  message: string;
+  begin: number;
+  end: number;
+  value: string;
+};
diff --git a/packages/framework/src/filters/validators.ts b/packages/framework/src/filters/validators.ts
new file mode 100644
index 00000000000..996c85e1428
--- /dev/null
+++ b/packages/framework/src/filters/validators.ts
@@ -0,0 +1,11 @@
+import { toSentenceArgsValidator } from './to-sentence';
+import { LiquidFilterIssue } from './types';
+
+type FilterValidators = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [key: string]: (...args: any[]) => LiquidFilterIssue[];
+};
+
+export const FILTER_VALIDATORS: FilterValidators = {
+  toSentence: toSentenceArgsValidator,
+};
diff --git a/packages/shared/src/dto/workflows/step-content-issue.enum.ts b/packages/shared/src/dto/workflows/step-content-issue.enum.ts
index b868e8ce65f..a5a1a6b84d0 100644
--- a/packages/shared/src/dto/workflows/step-content-issue.enum.ts
+++ b/packages/shared/src/dto/workflows/step-content-issue.enum.ts
@@ -1,5 +1,6 @@
 export enum StepContentIssueEnum {
   ILLEGAL_VARIABLE_IN_CONTROL_VALUE = 'ILLEGAL_VARIABLE_IN_CONTROL_VALUE',
+  INVALID_FILTER_ARG_IN_VARIABLE = 'INVALID_FILTER_ARG_IN_VARIABLE',
   MISSING_VALUE = 'MISSING_VALUE',
   TIER_LIMIT_EXCEEDED = 'TIER_LIMIT_EXCEEDED',
 }


Reviews:
user: login: LetItRock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:34:34Z
user: login: SokratisVidros
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:07:17Z
user: login: SokratisVidros
body: My bad, I wanted to request changes :)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:07:33Z
user: login: LetItRock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:11:48Z
user: login: LetItRock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:21:25Z
user: login: LetItRock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:25:02Z
user: login: LetItRock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:38:40Z
user: login: SokratisVidros
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T08:14:17Z
user: login: SokratisVidros
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:16:47Z

Review comments:
diff_hunk: @@ -148,6 +148,13 @@ export class BuildStepIssuesUsecase {
         // eslint-disable-next-line no-param-reassign
         issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => {
           const message = error.message ? error.message.split(' line:')[0] : '';
+          if ('filterMessage' in error) {
+            return {
+              message: `Variable ${error.output} filter ${error.filterMessage}`,
+              issueType: StepContentIssueEnum.INVALID_FILTER_ARG_IN_VARIABLE,
+              variableName: error.output,
+            };
+          }
path: apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
user: login: LetItRock
body: The LiquidJS filter errors have the `filterMessage` field so this way we differentiate it from the variable errors.
In the future, there will be more fields like `filterBegin: number; filterEnd: number`, which could tell the exact place in the template where the error is.
created_at: 2025-04-22T16:18:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,103 +40,13 @@ export function buildVariables(
     };
   }
 
-  const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue));
+  const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
+    template: JSON.stringify(variableControlValue),
+    variableSchema,
+  });
path: apps/api/src/app/workflows-v2/util/build-variables.ts
user: login: LetItRock
body: We now have only one place where we create valid/invalid variables.
This change also simplifies the future extension for the additional invalid variables information, like a `start, end` positions in the template, which the FE could then use to inform the user.
created_at: 2025-04-22T16:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,103 +40,13 @@ export function buildVariables(
     };
   }
 
-  const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue));
+  const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
+    template: JSON.stringify(variableControlValue),
+    variableSchema,
+  });
 
-  // don't compare against schema if it's not provided
-  if (!variableSchema) {
-    return { validVariables, invalidVariables };
-  }
-
-  const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables(
-    variableSchema || {},
-    validVariables
-  );
-
-  return {
-    validVariables: validSchemaVariables,
-    invalidVariables: [...invalidVariables, ...invalidSchemaVariables],
-  };
-}
-
-function isPropertyAllowed(schema: Record<string, unknown>, propertyPath: string) {
path: apps/api/src/app/workflows-v2/util/build-variables.ts
user: login: LetItRock
body: this function is used in the another file
created_at: 2025-04-22T16:23:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -82,9 +92,16 @@ function isLiquidErrors(error: unknown): error is LiquidErrors {
  * }
  *
  * @param template - The Liquid template string to parse
+ * @param variableSchema - The schema to validate the variables against
  * @returns Object containing arrays of valid and invalid variables found in the template
  */
-export function extractLiquidTemplateVariables(template: string): TemplateVariables {
+export function extractLiquidTemplateVariables({
+  template,
+  variableSchema,
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: pass the `variableSchema` to be able to validate the variables against it
created_at: 2025-04-22T16:25:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,39 +157,134 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
   return { validVariables, invalidVariables };
 }
 
-function parseByLiquid(rawOutput: string): TemplateVariables {
-  const validVariables: Variable[] = [];
-  const invalidVariables: Variable[] = [];
-  const parsed = parserEngine.parse(rawOutput) as unknown as Template[];
+function isPropertyAllowed(schema: Record<string, unknown> | undefined, propertyPath: string) {
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: reused
created_at: 2025-04-22T16:26:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,39 +157,134 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
   return { validVariables, invalidVariables };
 }
 
-function parseByLiquid(rawOutput: string): TemplateVariables {
-  const validVariables: Variable[] = [];
-  const invalidVariables: Variable[] = [];
-  const parsed = parserEngine.parse(rawOutput) as unknown as Template[];
+function isPropertyAllowed(schema: Record<string, unknown> | undefined, propertyPath: string) {
+  if (!schema) {
+    return true;
+  }
+
+  let currentSchema = { ...schema };
+  if (typeof currentSchema !== 'object') {
+    return false;
+  }
 
-  parsed.forEach((template: Template) => {
-    if (isOutputToken(template)) {
-      const result = extractProps(template);
+  const pathParts = propertyPath
+    .split('.')
+    .map((part) => {
+      // Split array notation into [propName, index]
+      const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
 
-      if (result.valid && result.props.length > 0) {
-        const path = result.props.reduce((acc, prop, i) => {
-          // if the prop is a number, preserve array notation (.[idx])
-          if (typeof prop === 'number') {
-            return `${acc}[${prop}]`;
-          }
+      return arrayMatch ? [arrayMatch[1], arrayMatch[2]] : [part];
+    })
+    .flat();
 
-          return i === 0 ? prop : `${acc}.${prop}`;
-        }, '');
+  for (const part of pathParts) {
+    const { properties, additionalProperties, type } = currentSchema;
 
-        validVariables.push({ name: path, output: rawOutput });
+    // Handle direct property access
+    if (properties?.[part]) {
+      currentSchema = properties[part];
+      continue;
+    }
+
+    // Handle array paths - valid if schema is array type
+    if (type === 'array') {
+      // Valid array index or property access
+      const isArrayIndex = !Number.isNaN(Number(part)) && Number(part) >= 0;
+      const arrayItemSchema = currentSchema.items as Record<string, unknown>;
+
+      if (isArrayIndex) {
+        currentSchema = arrayItemSchema;
+        continue;
       }
 
-      if (!result.valid) {
-        invalidVariables.push({ name: template?.token?.input, message: result.error, output: rawOutput });
+      if (arrayItemSchema?.properties?.[part]) {
+        currentSchema = arrayItemSchema.properties[part];
+        continue;
       }
     }
-  });
+
+    if (additionalProperties === true) {
+      return true;
+    }
+
+    return false;
+  }
+
+  return true;
+}
+
+function parseByLiquid({
+  rawOutput,
+  variableSchema,
+}: {
+  rawOutput: string;
+  variableSchema?: Record<string, unknown>;
+}): TemplateVariables {
+  const validVariables: Array<Variable> = [];
+  const invalidVariables: Array<InvalidVariable | InvalidFilterVariable> = [];
+  const parsed = parserEngine.parse(rawOutput);
+
+  parsed
+    .filter((template: Template) => isOutputToken(template))
+    .forEach((template: Template) => {
+      const result = extractProps(template);
+      const variableName = buildVariable(result.props);
+      const isDigestEventsVariable = !!variableName.match(/^steps\..+\.events$/);
+      const filters = extractFilters(template);
+      const filterIssues = validateFilters(filters, isDigestEventsVariable);
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: Extract filters from the parsed liquid template, and then validate the filters.
The single filter has the information about the filter name and args, also includes the position in the template.
created_at: 2025-04-22T16:29:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,3 +331,27 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?:
 
   return { valid: true, props: validProps };
 }
+
+function extractFilters(template: Template): Filter[] {
+  if (template instanceof Output) {
+    return template.value.filters;
+  }
+
+  return [];
+}
+
+function validateFilters(filters: Filter[], isDigestEventsVariable: boolean): LiquidFilterIssue[] {
+  return filters.reduce((acc, filter) => {
+    const validator = FILTER_VALIDATORS[filter.name];
+    if (!validator) return acc;
+
+    let args: unknown[] = [...filter.args];
+    if (filter.name === 'toSentence') {
+      args = [{ requireKeyPath: isDigestEventsVariable }, ...filter.args];
+    }
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: if the `toSentence` filter is used for the `steps.x.events` variable, then we want to validate that the `key path` is provided
created_at: 2025-04-22T16:33:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
+    issues.push({
+      message: 'Expected at least 1 argument',
+      begin: 0,
+      end: 0,
+      value: '',
+    });
+
+    return issues;
+  }
+
+  const argsSchema = z.object({
+    keyPath: requireKeyPath ? z.string().min(1, 'must be non-empty') : z.string().optional().default(DEFAULT_KEY_PATH),
+    limit: z
+      .number()
+      .optional()
+      .default(DEFAULT_LIMIT)
+      .refine((val) => {
+        return val >= 0;
+      }, 'must be greater than or equal to 0'),
+    overflowSuffix: z.string().optional().default(DEFAULT_OVERFLOW_SUFFIX),
+    wordsConnector: z.string().optional().default(DEFAULT_WORDS_CONNECTOR),
+    twoWordsConnector: z.string().optional().default(DEFAULT_TWO_WORDS_CONNECTOR),
+    lastWordConnector: z.string().optional().default(DEFAULT_LAST_WORD_CONNECTOR),
+  });
path: packages/framework/src/filters/to-sentence.ts
user: login: LetItRock
body: Zod schema for the filter args
created_at: 2025-04-22T16:33:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
+    issues.push({
+      message: 'Expected at least 1 argument',
+      begin: 0,
+      end: 0,
+      value: '',
+    });
+
+    return issues;
+  }
+
+  const argsSchema = z.object({
+    keyPath: requireKeyPath ? z.string().min(1, 'must be non-empty') : z.string().optional().default(DEFAULT_KEY_PATH),
+    limit: z
+      .number()
+      .optional()
+      .default(DEFAULT_LIMIT)
+      .refine((val) => {
+        return val >= 0;
+      }, 'must be greater than or equal to 0'),
+    overflowSuffix: z.string().optional().default(DEFAULT_OVERFLOW_SUFFIX),
+    wordsConnector: z.string().optional().default(DEFAULT_WORDS_CONNECTOR),
+    twoWordsConnector: z.string().optional().default(DEFAULT_TWO_WORDS_CONNECTOR),
+    lastWordConnector: z.string().optional().default(DEFAULT_LAST_WORD_CONNECTOR),
+  });
+
+  const argsObject: Record<string, number | string> = {};
+  args.forEach((arg, index) => {
+    if (!Array.isArray(arg)) {
+      let value: string | number = arg.getText();
+      if (arg.kind === TokenKind.Quoted) {
+        value = (arg as QuotedToken).content;
+      } else if (arg.kind === TokenKind.Number) {
+        value = (arg as NumberToken).content;
+      }
+      const argName = ARG_INDEX_TO_ARG_NAME[index];
+      argsObject[argName] = value;
+    }
+  });
path: packages/framework/src/filters/to-sentence.ts
user: login: LetItRock
body: convert args array to object that will be validated
created_at: 2025-04-22T16:34:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
+    issues.push({
+      message: 'Expected at least 1 argument',
+      begin: 0,
+      end: 0,
+      value: '',
+    });
+
+    return issues;
+  }
+
+  const argsSchema = z.object({
+    keyPath: requireKeyPath ? z.string().min(1, 'must be non-empty') : z.string().optional().default(DEFAULT_KEY_PATH),
+    limit: z
+      .number()
+      .optional()
+      .default(DEFAULT_LIMIT)
+      .refine((val) => {
+        return val >= 0;
+      }, 'must be greater than or equal to 0'),
+    overflowSuffix: z.string().optional().default(DEFAULT_OVERFLOW_SUFFIX),
+    wordsConnector: z.string().optional().default(DEFAULT_WORDS_CONNECTOR),
+    twoWordsConnector: z.string().optional().default(DEFAULT_TWO_WORDS_CONNECTOR),
+    lastWordConnector: z.string().optional().default(DEFAULT_LAST_WORD_CONNECTOR),
+  });
+
+  const argsObject: Record<string, number | string> = {};
+  args.forEach((arg, index) => {
+    if (!Array.isArray(arg)) {
+      let value: string | number = arg.getText();
+      if (arg.kind === TokenKind.Quoted) {
+        value = (arg as QuotedToken).content;
+      } else if (arg.kind === TokenKind.Number) {
+        value = (arg as NumberToken).content;
+      }
+      const argName = ARG_INDEX_TO_ARG_NAME[index];
+      argsObject[argName] = value;
+    }
+  });
+
+  const result = argsSchema.safeParse(argsObject);
+
+  if (!result.success) {
+    for (const error of result.error.issues) {
+      let type = 'string';
+      if ('type' in error) {
+        type = error.type;
+      }
+
+      const path = error.path[0];
+      const argIndexToArgName = Object.entries(ARG_INDEX_TO_ARG_NAME).find(([_, argName]) => argName === path);
+      const argIndex = argIndexToArgName ? parseInt(argIndexToArgName[0], 10) : null;
+      const token = typeof argIndex === 'number' ? args[argIndex] : null;
+
+      if (token && !Array.isArray(token)) {
+        issues.push({
+          message: `"toSentence" expects a ${type}${error.message ? ` that ${error.message}` : ''} for argument "${path}"`,
+          begin: token.begin,
+          end: token.end,
+          value: token.getText(),
+        });
+      }
+    }
path: packages/framework/src/filters/to-sentence.ts
user: login: LetItRock
body: build the validation errors
created_at: 2025-04-22T16:34:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -82,9 +92,16 @@ function isLiquidErrors(error: unknown): error is LiquidErrors {
  * }
  *
  * @param template - The Liquid template string to parse
+ * @param variableSchema - The schema to validate the variables against
  * @returns Object containing arrays of valid and invalid variables found in the template
  */
-export function extractLiquidTemplateVariables(template: string): TemplateVariables {
+export function extractLiquidTemplateVariables({
+  template,
+  variableSchema,
+}: {
+  template: string;
+  variableSchema?: Record<string, unknown>;
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: SokratisVidros
body: If not mistaken, we have a JSON schema type in @novu/shared that we can reuse for extra type safety.
created_at: 2025-04-22T16:57:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +148,13 @@ export class BuildStepIssuesUsecase {
         // eslint-disable-next-line no-param-reassign
         issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => {
path: apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
user: login: SokratisVidros
body: ```suggestion
        issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((variable) => {
```
created_at: 2025-04-22T17:03:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +148,13 @@ export class BuildStepIssuesUsecase {
         // eslint-disable-next-line no-param-reassign
         issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => {
path: apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts
user: login: SokratisVidros
body: Please apply the proper naming in the iteration, this is a variable, not an error.
created_at: 2025-04-22T17:03:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,16 +31,25 @@ export type Variable = {
   /** The surrounding context where the variable was found, useful for error messages */
   context?: string;
 
+  /** The full liquid output string (e.g. "{{user.name | upcase}}") */
+  output: string;
+};
+
+type InvalidContentVariable = Variable & {
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: SokratisVidros
body: I am not sure I prefer having two separate types for this. It introduces too many types and I think it makes the code more cumbersome that it should be. Variables are still variables, regardless if they appear in filters or in content, right? 

Alternatively, I believe extending the `Variable` object with extra optional attributes is a better idea. For now, the `filterMessage` is only used [here](https://github.com/novuhq/novu/pull/8178/files#diff-ea0a2aabda2aef01160c2fa733c30b9afd1fab8f190d9658aedfbbee497e468fR151) to define the error message. 

My suggestion is just populate the existing `variable.message` field for now with the following copywriting:

`Filter ${error.filterMessage} in ${variable.name}`.

Notice that I used the name not the full output of the variable for a shorter and friendlier message. 
created_at: 2025-04-22T17:04:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,3 +331,27 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?:
 
   return { valid: true, props: validProps };
 }
+
+function extractFilters(template: Template): Filter[] {
+  if (template instanceof Output) {
+    return template.value.filters;
+  }
+
+  return [];
+}
+
+function validateFilters(filters: Filter[], isDigestEventsVariable: boolean): LiquidFilterIssue[] {
+  return filters.reduce((acc, filter) => {
+    const validator = FILTER_VALIDATORS[filter.name];
+    if (!validator) return acc;
+
+    let args: unknown[] = [...filter.args];
+    if (filter.name === 'toSentence') {
+      args = [{ requireKeyPath: isDigestEventsVariable }, ...filter.args];
+    }
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: SokratisVidros
body: Don't we want to always require the key path?
created_at: 2025-04-22T17:04:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
path: packages/framework/src/filters/to-sentence.ts
user: login: SokratisVidros
body: @LetItRock I am not sure I follow the logic here. Can't we verify args only using zod by converting the filter arguyments arguments to an object without having to do all this pre and post processing?
created_at: 2025-04-22T17:07:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,16 +31,25 @@ export type Variable = {
   /** The surrounding context where the variable was found, useful for error messages */
   context?: string;
 
+  /** The full liquid output string (e.g. "{{user.name | upcase}}") */
+  output: string;
+};
+
+type InvalidContentVariable = Variable & {
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: I've separated the types because in the future there could be details about the filter start and end positions. Also we don't have a way to differentiate these validation errors to build the different messages, that's why I introduced the `filterMessage` field.
created_at: 2025-04-22T21:11:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,16 +31,25 @@ export type Variable = {
   /** The surrounding context where the variable was found, useful for error messages */
   context?: string;
 
+  /** The full liquid output string (e.g. "{{user.name | upcase}}") */
+  output: string;
+};
+
+type InvalidContentVariable = Variable & {
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: So I'll revert to one type but with two fields for message, unless we want the `type`.
created_at: 2025-04-22T21:21:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,3 +331,27 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?:
 
   return { valid: true, props: validProps };
 }
+
+function extractFilters(template: Template): Filter[] {
+  if (template instanceof Output) {
+    return template.value.filters;
+  }
+
+  return [];
+}
+
+function validateFilters(filters: Filter[], isDigestEventsVariable: boolean): LiquidFilterIssue[] {
+  return filters.reduce((acc, filter) => {
+    const validator = FILTER_VALIDATORS[filter.name];
+    if (!validator) return acc;
+
+    let args: unknown[] = [...filter.args];
+    if (filter.name === 'toSentence') {
+      args = [{ requireKeyPath: isDigestEventsVariable }, ...filter.args];
+    }
path: apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts
user: login: LetItRock
body: no, if the user provides the `payload.items` we don't know the type, and it could be an array of strings, or objects, we don't know
created_at: 2025-04-22T21:25:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,3 +78,83 @@ export function toSentence(
 
   return `${shownItems.join(wordsConnector)}${connector}${moreCount} ${pluralize(overflowSuffix, moreCount)}`;
 }
+
+/**
+ * Validate the arguments for the toSentence filter
+ * @param options Options for validation. Can include requireKeyPath to make keyPath required.
+ * @param args The arguments for the toSentence filter
+ * @returns An array of issues with the validation errors
+ */
+export function toSentenceArgsValidator(
+  options: { requireKeyPath?: boolean } = {},
+  ...args: Filter['args']
+): LiquidFilterIssue[] {
+  const { requireKeyPath = false } = options;
+  const issues: LiquidFilterIssue[] = [];
+  if (args.length < 1) {
path: packages/framework/src/filters/to-sentence.ts
user: login: LetItRock
body: I'm not sure what you mean by pre/post processing. Can you clarify or pseudo code?

> converting the filter arguyments arguments to an object

This is what I do at line 121

---

But if it's about the args, there are a few reasons it takes an `args` array:
- the LiquidJS engine produces the output tokens for variables where the filters have `args` as an array
- we don't need to have the conversion logic on the API side for every filter, so the code is generic there
- it's aligned with the order of defined args in the filter function
created_at: 2025-04-22T21:38:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-22T16:15:09Z
body: <p><a href="https://linear.app/novu/issue/NV-5705/the-tosentence-filter-with-the-stepsxevents-variable-and-no-key-should">NV-5705 The toSentence filter with the `steps.x.events` variable and no key should result in validation error</a></p>
author_association: NONE
user: login: netlify[bot]
created_at: 2025-04-22T16:15:30Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *dashboard-v2-novu-staging* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | b411e6767f34b50272cf0c5d6e5f2e071b9da635 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/dashboard-v2-novu-staging/deploys/6808e3b904ebe600081c4b9a |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-8178.dashboard-v2.novu-staging.co](https://deploy-preview-8178.dashboard-v2.novu-staging.co) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTgxNzguZGFzaGJvYXJkLXYyLm5vdnUtc3RhZ2luZy5jbyJ9.R8X6IKu6qnHZEVYBgr8gylvnl4sEAtF8Y-Rw6NzLDDg)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [dashboard-v2-novu-staging Preview](https://deploy-preview-8178.dashboard-v2.novu-staging.co) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/dashboard-v2-novu-staging/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-04-22T16:28:44Z
body: 
[Open in StackBlitz](https://pkg.pr.new/template/c9ad0826-1df3-44b9-9a9b-4c519f80cf7a)


```
npm i https://pkg.pr.new/novuhq/novu/@novu/framework@8178
```


```
npm i https://pkg.pr.new/novuhq/novu@8178
```


```
npm i https://pkg.pr.new/novuhq/novu/@novu/providers@8178
```


```
npm i https://pkg.pr.new/novuhq/novu/@novu/shared@8178
```


_commit: <a href="https://github.com/novuhq/novu/runs/41013177997"><code>b411e67</code></a>_

author_association: NONE