diff --git a/.changeset/major-mugs-glow.md b/.changeset/major-mugs-glow.md new file mode 100644 index 0000000..9ff405c --- /dev/null +++ b/.changeset/major-mugs-glow.md @@ -0,0 +1,5 @@ +--- +"@codee-sh/medusa-plugin-automations": patch +--- + +Add array operators, relation support, and documentation updates diff --git a/.changeset/open-plants-laugh.md b/.changeset/open-plants-laugh.md new file mode 100644 index 0000000..ed0e979 --- /dev/null +++ b/.changeset/open-plants-laugh.md @@ -0,0 +1,5 @@ +--- +"@codee-sh/medusa-plugin-automations": patch +--- + +Clean files by prettier diff --git a/.github/release.yml b/.github/release.yml index cc2513d..4885a53 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -9,27 +9,15 @@ changelog: - title: Features labels: - "type: feature" - - "type: enhancement" - - enhancement - - feature - - title: Bug Fixes + - title: Bugs labels: - "type: bug" - - "type: bugfix" - - bug - - fix - - bugfix - title: Improvements labels: - "type: improvement" - - "type: refactor" - - improvement - - refactor - title: Documentation labels: - "type: docs" - - "type: documentation" - - documentation - docs - title: Dependencies labels: diff --git a/README.md b/README.md index 96bb651..ee29e41 100755 --- a/README.md +++ b/README.md @@ -4,15 +4,13 @@ A comprehensive automation plugin for Medusa v2 that provides a flexible rule-ba ## Features -- **Automation Triggers**: Create automations triggered by events, schedules, or manual actions -- **Automation Management**: Create, edit, and delete automation triggers with automatic cleanup of related data -- **Rule-Based Conditions**: Define complex conditions using rule attributes (e.g., inventory levels, order status) -- **Multiple Action Types**: Execute various actions including email notifications, Slack messages, SMS, push notifications, and custom actions -- **Event Subscribers**: Built-in subscribers for common Medusa events (inventory updates, order events, payment events) -- **Admin Panel**: Manage automations directly from Medusa Admin -- **Flexible Rules**: Support for multiple rule types and operators (equals, greater than, less than, contains, etc.) -- **Slack Notifications**: Rich Slack notifications with Block Kit support including headers, action buttons, and dividers -- **Extensible Actions**: Add custom action handlers to extend automation capabilities +- **Automation Triggers**: Create automations triggered by events, schedules, or manual actions ([see details](#automation-triggers)) +- **Rule-Based Conditions**: Define complex conditions with support for arrays, relations, and multiple data types ([see details](#rules-and-conditions)) +- **Rich Attribute Support**: Pre-configured attributes for Products, Variants, Tags, Categories, and Inventory ([see available attributes](./docs/configuration.md#available-attributes-reference)) +- **Multiple Action Types**: Execute various actions including email notifications, Slack messages, SMS, push notifications, and custom actions ([see details](#actions)) +- **Event Subscribers**: Built-in subscribers for common Medusa events ([see available events](./docs/configuration.md#available-subscribers)) +- **Admin Panel**: Manage automations directly from Medusa Admin ([see details](#admin-panel)) +- **Extensible**: Add custom action handlers and extend automation capabilities - **Type-Safe**: Full TypeScript support with exported types and workflows ## Compatibility @@ -50,6 +48,8 @@ The plugin includes database migrations for automation models. Run migrations to medusa migrations run ``` +See [Database Migrations](./docs/configuration.md#database-migrations) for more details about the created tables. + ### 3. Access Admin Panel Navigate to **Notifications > Automations** in your Medusa Admin dashboard, or directly access: @@ -63,17 +63,20 @@ Navigate to **Notifications > Automations** in your Medusa Admin dashboard, or d ### Automation Triggers Automations are triggered by: -- **Events**: Medusa events (e.g., `inventory.inventory-level.updated`, `order.placed`) +- **Events**: Medusa events (e.g., `inventory.inventory-level.updated`, `product.updated`) - **Schedule**: Time-based triggers with configurable intervals (In progress) - **Manual**: Triggered manually from the admin panel +See [Available Subscribers](./docs/configuration.md#available-subscribers) in the configuration documentation for a complete list of supported events. + ### Rules and Conditions -Each automation can have multiple rules that define when actions should be executed: +Each automation can have multiple rules that define when actions should be executed. Rules support primitive fields, relations (arrays), nested objects, and various operators for complex conditions. -- **Rule Attributes**: Available attributes for conditions -- **Operators**: Comparison operators (equals, greater than, less than, contains, in, etc.) -- **Rule Values**: Values to compare against +For detailed information, see: +- [Available Attributes Reference](./docs/configuration.md#available-attributes-reference) - Complete list of attributes for each event type +- [Rule Operators](./docs/configuration.md#rule-operators) - All supported operators with examples +- [Rule Values](./docs/configuration.md#rule-values) - Supported data types and usage ### Actions @@ -83,7 +86,7 @@ When automation rules pass, actions are executed. Supported action types include - **Slack**: Send Slack messages with Block Kit formatting - **Custom**: Extend with custom action handlers -See [Configuration Documentation](./docs/configuration.md) for details on built-in subscribers, available actions, and extending functionality. +See [Actions](./docs/configuration.md#actions) and [Slack Notification Provider](./docs/configuration.md#slack-notification-provider) in the configuration documentation for details on configuring and extending actions. ## Admin Panel diff --git a/docs/admin.md b/docs/admin.md index 1192c01..9c3f03f 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -37,16 +37,25 @@ Automations can be triggered by: Each automation can have multiple rules that define conditions: -- **Rule Attributes**: Select from available attributes (e.g., `inventory_level.available_quantity`) -- **Operators**: Choose comparison operators (equals, greater than, less than, contains, in, etc.) -- **Values**: Set values to compare against +- **Rule Attributes**: Select from available attributes including: + - Primitive fields: `product.title`, `inventory_level.available_quantity` + - Relations: `product.tags.id`, `product.categories.name` (arrays) + - Nested objects: `inventory_level.inventory_item.*` +- **Operators**: Choose comparison operators: + - **Basic**: `equals`, `not equals`, `greater than`, `less than`, `greater than or equal`, `less than or equal` + - **Array operations**: `in`, `not in`, `contains`, `not contains` + - **Null checks**: `empty`, `not empty` +- **Values**: Set values to compare against: + - **Single values**: Enter a single string or number (e.g., `10`, `"Electronics"`) + - **Array values**: Use the chip input to add multiple values (e.g., tag IDs, category names) + - **No value**: For `empty` and `not empty` operators, no value input is required #### Actions When all rules pass, actions are executed: -- **Channels**: Configure delivery channels (email, slack, admin, etc.) -- **Metadata**: Add custom metadata for actions +- **Channels**: Configure delivery channels (email, slack etc.) +- **Metadata**: Add custom config for actions ## Using the Admin Panel @@ -60,13 +69,19 @@ When all rules pass, actions are executed: - If schedule: Set interval in minutes - Set a name and description 4. **Add Rules**: - - Select rule attributes from available options - - Choose operators - - Set comparison values - - Add multiple rules as needed + - Select rule attributes from available options (including relations and nested objects) + - Choose operators based on your needs: + - Use `in` or `not in` for checking if a value exists in an array + - Use `contains` or `not contains` for partial matches in arrays + - Use `empty` or `not empty` to check for null/empty values + - Set comparison values: + - For array operators (`in`, `not in`, `contains`, `not contains`): Use the chip input to add multiple values + - For basic operators: Enter a single value + - For `empty`/`not empty`: No value input needed + - Add multiple rules as needed (all rules must pass for the automation to trigger) 5. **Configure Actions**: - Set delivery channels - - Add metadata if needed + - Add config if needed 6. **Save**: Save the automation configuration ### Editing an Automation @@ -91,21 +106,37 @@ Create an automation that sends a notification when inventory levels drop below - **Rule**: `inventory_level.available_quantity` is less than `10` - **Action**: Send email notification -### High Stock Alert +### Product Tag Automation -Create an automation for when inventory exceeds a certain level: +Create an automation that triggers when a product has specific tags: + +- **Trigger**: Event `product.product.updated` +- **Rule**: `product.tags.id` is `in` `[tag-premium, tag-featured]` (use chip input for multiple tag IDs) +- **Action**: Send Slack notification + +### Category-Based Automation + +Create an automation for products in specific categories: + +- **Trigger**: Event `product.product.created` +- **Rule**: `product.categories.name` contains `"Electronics"` (or use `in` operator with multiple category names) +- **Action**: Send email notification + +### Empty Inventory Check + +Create an automation that triggers when inventory is empty: - **Trigger**: Event `inventory.inventory-level.updated` -- **Rule**: `inventory_level.stocked_quantity` is greater than `1000` -- **Action**: Send admin notification +- **Rule**: `inventory_level.available_quantity` is `empty` +- **Action**: Send Slack notification -### Scheduled Inventory Report +### High Stock Alert -Create a scheduled automation that runs periodically: +Create an automation for when inventory exceeds a certain level: -- **Trigger**: Schedule with interval of `1440` minutes (daily) -- **Rules**: Configure conditions for what to include in the report -- **Action**: Generate and send inventory report +- **Trigger**: Event `inventory.inventory-level.updated` +- **Rule**: `inventory_level.stocked_quantity` is greater than `1000` +- **Action**: Send Slack notification ## Best Practices @@ -114,4 +145,10 @@ Create a scheduled automation that runs periodically: 3. **Monitor Performance**: Keep an eye on automation execution and performance 4. **Use Appropriate Triggers**: Choose the right trigger type for your use case 5. **Combine Rules**: Use multiple rules to create complex conditions -6. **Document Automations**: Add descriptions to explain automation purpose \ No newline at end of file +6. **Document Automations**: Add descriptions to explain automation purpose +7. **Choose the Right Operator**: + - Use `in`/`not in` for exact matches in arrays (e.g., checking if product has specific tags) + - Use `contains`/`not contains` for partial matches (e.g., checking if category name contains a substring) + - Use `empty`/`not empty` for null checks +8. **Use Array Values Correctly**: When using array operators (`in`, `not in`, `contains`, `not contains`), use the chip input to add multiple values +9. **Leverage Relations**: Use relation-based attributes (e.g., `product.tags.id`, `product.categories.name`) to create powerful automations based on related data \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 4cb9c00..fd45a21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,10 +30,12 @@ medusa migrations run This will create the following tables: - `mpn_automation_trigger` - Stores automation triggers - `mpn_automation_rule` - Stores automation rules -- `mpn_automation_rule_value` - Stores rule values +- `mpn_automation_rule_value` - Stores rule values (uses JSONB to support strings, numbers, arrays, and null) - `mpn_automation_state` - Stores automation state - `npm_automation_action` - Stores automation actions +**Note**: The `mpn_automation_rule_value.value` column uses JSONB to support various data types (strings, numbers, arrays, null), enabling complex rule conditions with array operations and relation-based attributes. + ## Built-in Subscribers The plugin includes built-in subscribers that listen to Medusa events and evaluate automation rules. These subscribers are registered automatically when the plugin is loaded. @@ -45,7 +47,8 @@ The plugin includes built-in subscribers that listen to Medusa events and evalua Evaluates automations when inventory levels are updated. - **Event**: `inventory.inventory-level.updated` -- **Context**: Provides `inventory_level` data with related `inventory_item` +- **Context**: Provides `inventory_level` data with related `inventory_item` and `stock_locations` +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section #### `inventory.inventory-item.updated` @@ -53,6 +56,7 @@ Evaluates automations when inventory items are updated. - **Event**: `inventory.inventory-item.updated` - **Context**: Provides `inventory_item` data +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section #### `inventory.inventory-reservation-item.updated` (in progress) @@ -61,33 +65,46 @@ Evaluates automations when inventory reservations are updated. - **Event**: `inventory.inventory-reservation-item.updated` - **Context**: Provides reservation data -#### `order.placed` (in progress) +#### `product.updated` + +Evaluates automations when products are updated. + +- **Event**: `product.updated` +- **Context**: Provides `product` data with relations (tags, categories, variants, type, collection) +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section + +#### `product-variant.updated` -Evaluates automations when orders are placed. +Evaluates automations when product variants are updated. -- **Event**: `order.placed` -- **Context**: Provides order data +- **Event**: `product-variant.updated` +- **Context**: Provides `product_variant` data +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section -#### `order.completed` (in progress) +#### `product-tag.updated` -Evaluates automations when orders are completed. +Evaluates automations when product tags are updated. -- **Event**: `order.completed` -- **Context**: Provides order data +- **Event**: `product-tag.updated` +- **Context**: Provides `product_tag` data +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section -#### `payment.captured` (in progress) +#### `product-category.updated` -Evaluates automations when payments are captured. +Evaluates automations when product categories are updated. -- **Event**: `payment.captured` -- **Context**: Provides payment data +- **Event**: `product-category.updated` +- **Context**: Provides `product_category` data +- **Available Attributes**: See [Available Attributes Reference](#available-attributes-reference) section ### How Subscribers Work 1. **Event Detection**: Subscribers listen to Medusa events -2. **Data Fetching**: When an event is triggered, the subscriber fetches relevant data +2. **Data Fetching**: When an event is triggered, the subscriber fetches relevant data (including relations when needed) 3. **Trigger Evaluation**: The subscriber retrieves all active triggers for the event -4. **Rule Evaluation**: For each trigger, rules are evaluated against the event context +4. **Rule Evaluation**: For each trigger, rules are evaluated against the event context: + - Rules can check primitive fields, relations (arrays), and nested objects + - Supports various operators including array operations (`in`, `not in`, `contains`, `not contains`) and null checks (`empty`, `not empty`) 5. **Action Execution**: If all rules pass, configured actions are executed (e.g., send notifications, execute custom logic) ## Actions @@ -230,11 +247,181 @@ module.exports = defineConfig({ - Check that rule attributes exist in the context data - Verify that operators and values are correct -- Ensure rule values match the expected data types +- Ensure rule values match the expected data types: + - For array operators (`in`, `not in`, `contains`, `not contains`): Use array values + - For basic operators: Use single string or number values + - For `empty`/`not empty`: No value needed +- Verify relation-based attributes are correctly formatted (e.g., `product.tags.id` for array relations) +- Check that array attributes are being compared with array operators ### Migrations Not Running - Ensure you're running migrations after plugin installation - Check that database connection is properly configured - Verify that plugin is correctly registered in `medusa-config.ts` +- **Note**: If upgrading from an older version, ensure the `mpn_automation_rule_value.value` column has been migrated from `text` to `jsonb` to support array values and new operators + +## Rule Operators + +The plugin supports various operators for rule conditions: + +### Basic Operators +- `equals` (`eq`) - Exact match +- `not equals` (`ne`) - Not equal +- `greater than` (`gt`) - Numeric comparison +- `less than` (`lt`) - Numeric comparison +- `greater than or equal` (`gte`) - Numeric comparison +- `less than or equal` (`lte`) - Numeric comparison + +### Array Operators +- `in` - Check if value exists in array (e.g., `product.tags.id IN [tag-1, tag-2]`) +- `not in` - Check if value does not exist in array +- `contains` - Check if array contains value (partial match) +- `not contains` - Check if array does not contain value + +### Null Checks +- `empty` - Check if value is null or empty +- `not empty` - Check if value is not null or empty + +## Rule Values + +Rule values support multiple data types stored as JSONB: + +- **Strings**: `"Electronics"` +- **Numbers**: `10`, `100.5` +- **Arrays**: `["tag-1", "tag-2"]` or `[1, 2, 3]` +- **Null**: `null` (for empty checks) + +When using array operators (`in`, `not in`, `contains`, `not contains`), provide array values. For basic operators, provide single values. + +## Available Attributes Reference + +This section provides a comprehensive list of available attributes for each event type. These attributes can be used in rule conditions. + +### Inventory Level Attributes + +Available for events: `inventory.inventory-level.created`, `inventory.inventory-level.updated`, `inventory.inventory-level.deleted` + +**Primitive Fields:** +- `inventory_level.available_quantity` - Available quantity +- `inventory_level.reserved_quantity` - Reserved quantity +- `inventory_level.stocked_quantity` - Stocked quantity +- `inventory_level.location_id` - Location ID +- `inventory_level.inventory_item_id` - Inventory item ID +- `inventory_level.created_at` - Creation timestamp +- `inventory_level.updated_at` - Update timestamp + +**Relation-Based Attributes:** +- `inventory_level.inventory_item.*` - All inventory item fields (object) +- `inventory_level.stock_locations.id` - Stock location IDs (array) +- `inventory_level.stock_locations.name` - Stock location names (array) +- `inventory_level.stock_locations.address` - Stock location addresses (array) +- `inventory_level.stock_locations.metadata` - Stock location metadata (array) + +### Inventory Item Attributes + +Available for events: `inventory.inventory-item.created`, `inventory.inventory-item.updated`, `inventory.inventory-item.deleted` + +**Primitive Fields:** +- `inventory_item.sku` - SKU code +- `inventory_item.origin_country` - Origin country +- `inventory_item.hs_code` - HS code +- `inventory_item.mid_code` - MID code +- `inventory_item.material` - Material +- `inventory_item.weight` - Weight +- `inventory_item.length` - Length +- `inventory_item.height` - Height +- `inventory_item.width` - Width +- `inventory_item.metadata` - Metadata (object) +- `inventory_item.created_at` - Creation timestamp +- `inventory_item.updated_at` - Update timestamp + +### Product Attributes + +Available for events: `product.updated` + +**Primitive Fields:** +- `product.id` - Product ID +- `product.title` - Product title +- `product.description` - Product description +- `product.subtitle` - Product subtitle +- `product.handle` - Product handle +- `product.is_giftcard` - Is gift card +- `product.status` - Product status +- `product.thumbnail` - Thumbnail URL +- `product.hs_code` - HS code +- `product.origin_country` - Origin country +- `product.mid_code` - MID code +- `product.material` - Material +- `product.weight` - Weight +- `product.length` - Length +- `product.height` - Height +- `product.width` - Width +- `product.metadata` - Metadata (object) +- `product.created_at` - Creation timestamp +- `product.updated_at` - Update timestamp +- `product.deleted_at` - Deletion timestamp + +**Relation-Based Attributes (Arrays):** +- `product.tags.id` - Product tag IDs (array) +- `product.tags.value` - Product tag values (array) +- `product.categories.id` - Category IDs (array) +- `product.categories.name` - Category names (array) +- `product.categories.handle` - Category handles (array) +- `product.variants.*` - Product variants (array of objects) +- `product.type.*` - Product type (object) +- `product.collection.*` - Product collection (object) + +### Product Variant Attributes + +Available for events: `product-variant.updated` + +**Primitive Fields:** +- `product_variant.id` - Variant ID +- `product_variant.title` - Variant title +- `product_variant.sku` - SKU code +- `product_variant.barcode` - Barcode +- `product_variant.ean` - EAN code +- `product_variant.upc` - UPC code +- `product_variant.allow_backorder` - Allow backorder +- `product_variant.manage_inventory` - Manage inventory +- `product_variant.hs_code` - HS code +- `product_variant.origin_country` - Origin country +- `product_variant.mid_code` - MID code +- `product_variant.material` - Material +- `product_variant.weight` - Weight +- `product_variant.length` - Length +- `product_variant.height` - Height +- `product_variant.width` - Width +- `product_variant.metadata` - Metadata (object) +- `product_variant.variant_rank` - Variant rank +- `product_variant.product_id` - Product ID +- `product_variant.created_at` - Creation timestamp +- `product_variant.updated_at` - Update timestamp + +### Product Tag Attributes + +Available for events: `product-tag.updated` + +**Primitive Fields:** +- `product_tag.id` - Tag ID +- `product_tag.value` - Tag value +- `product_tag.created_at` - Creation timestamp +- `product_tag.updated_at` - Update timestamp + +### Product Category Attributes + +Available for events: `product-category.updated` + +**Primitive Fields:** +- `product_category.id` - Category ID +- `product_category.name` - Category name +- `product_category.description` - Category description +- `product_category.handle` - Category handle +- `product_category.is_active` - Is active +- `product_category.is_internal` - Is internal +- `product_category.rank` - Category rank +- `product_category.parent_category_id` - Parent category ID +- `product_category.created_at` - Creation timestamp +- `product_category.updated_at` - Update timestamp diff --git a/package.json b/package.json index 20bdc44..e08f4fe 100755 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "description": "Medusa plugin for automations.", "author": "Codee (https://codee.dev)", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/codee-sh/medusa-plugin-automations.git" + }, "publishConfig": { "access": "public" }, @@ -67,6 +71,7 @@ "@types/react-dom": "^18.2.25", "awilix": "^8.0.1", "dotenv": "^17.2.3", + "prettier": "^3.0.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/admin/automations/automations-form/automations-actions-form/index.tsx b/src/admin/automations/automations-form/automations-actions-form/index.tsx index 6a888b0..bb1c252 100644 --- a/src/admin/automations/automations-form/automations-actions-form/index.tsx +++ b/src/admin/automations/automations-form/automations-actions-form/index.tsx @@ -16,7 +16,6 @@ export function AutomationsActionsForm({ isOpen?: boolean availableActionsData?: any }) { - // Reset action configs when eventName changes to ensure templates are updated // useEffect(() => { // const actions = form.getValues("actions.items") || [] @@ -70,11 +69,15 @@ export function AutomationsActionsForm({ return } - form.setValue(`actions.items.${index}.action_type`, value, { - shouldValidate: false, - shouldDirty: true, - }) - + form.setValue( + `actions.items.${index}.action_type`, + value, + { + shouldValidate: false, + shouldDirty: true, + } + ) + // Clear validation errors first to prevent showing errors from previous action type form.clearErrors(`actions.items.${index}`) @@ -83,7 +86,7 @@ export function AutomationsActionsForm({ (a) => a.value === value ) const fields = actionData?.fields - + // Reset config when action type changes to prevent sending // fields from previous action type in payload form.setValue( @@ -158,7 +161,11 @@ export function AutomationsActionsForm({ actionTypeField.value ?? "" } onValueChange={(value) => { - actionTypeValueChange(index, value, isExistingAction) + actionTypeValueChange( + index, + value, + isExistingAction + ) }} disabled={isExistingAction} > diff --git a/src/admin/automations/automations-form/automations-create-form/index.tsx b/src/admin/automations/automations-form/automations-create-form/index.tsx index c79b044..7f25536 100644 --- a/src/admin/automations/automations-form/automations-create-form/index.tsx +++ b/src/admin/automations/automations-form/automations-create-form/index.tsx @@ -191,7 +191,9 @@ export function AutomationsCreateForm() { - Create Automation + + Create Automation +
("") - const [eventName, setEventName] = useState(undefined) + const [eventName, setEventName] = useState< + string | undefined + >(undefined) useEffect(() => { if (Tab.GENERAL === tab) { @@ -80,7 +82,7 @@ export function AutomationsEditForm({ const { data: automationsActionsData, - isLoading: isAutomationsActionsLoading + isLoading: isAutomationsActionsLoading, } = useListAutomationsActions({ trigger_id: id, extraKey: [id], @@ -91,7 +93,7 @@ export function AutomationsEditForm({ const { data: availableActionsData } = useAvailableActions({ enabled: open, - eventName: eventName + eventName: eventName, }) const { @@ -181,7 +183,12 @@ export function AutomationsEditForm({ }, }) } - }, [open, automationsTriggerData, automationsRulesData, automationsActionsData]) + }, [ + open, + automationsTriggerData, + automationsRulesData, + automationsActionsData, + ]) // Reset form when modal is closed useEffect(() => { @@ -251,7 +258,7 @@ export function AutomationsEditForm({ } await editAutomationRule(items) - + queryClient.invalidateQueries({ queryKey: ["automations-rules", id], }) @@ -270,7 +277,7 @@ export function AutomationsEditForm({ trigger_id: id, actions: data.actions?.items || [], } - + await editAutomationAction(items) queryClient.invalidateQueries({ @@ -332,7 +339,9 @@ export function AutomationsEditForm({ - Edit Automation + + Edit Automation +
)} diff --git a/src/admin/automations/automations-form/automations-rules-form/index.tsx b/src/admin/automations/automations-form/automations-rules-form/index.tsx index 0a1927e..48a8fa1 100644 --- a/src/admin/automations/automations-form/automations-rules-form/index.tsx +++ b/src/admin/automations/automations-form/automations-rules-form/index.tsx @@ -1,9 +1,10 @@ -import { Input, Label, Select, Button } from "@medusajs/ui" +import { Label, Select, Button } from "@medusajs/ui" import { useAvailableEvents } from "../../../../hooks/api/available-events" import { OPERATOR_TYPES } from "../../../../modules/mpn-automation/types/types" import { Controller, useFieldArray } from "react-hook-form" import { useMemo } from "react" import { Trash, Plus } from "@medusajs/icons" +import { RuleValueInput } from "./rule-value-input" export function AutomationsRulesForm({ form, @@ -109,7 +110,10 @@ export function AutomationsRulesForm({ attribute.value || "ss" } > - {attribute.label} ({attribute.value}) + {attribute.label}{" "} + + ({attribute.value}) + ) )} @@ -163,26 +167,11 @@ export function AutomationsRulesForm({ )} /> - ( - <> - - { - field.onChange(e.target.value) - }} - onBlur={field.onBlur} - ref={field.ref} - /> - {fieldState.error && ( - - {fieldState.error.message} - - )} - + name={`rules.items.${index}.rule_values.0.value`} + operator={form.watch( + `rules.items.${index}.operator` )} />
@@ -205,7 +194,7 @@ export function AutomationsRulesForm({ className="w-full" > - Add Item + Add condition
diff --git a/src/admin/automations/automations-form/automations-rules-form/rule-value-input/index.ts b/src/admin/automations/automations-form/automations-rules-form/rule-value-input/index.ts new file mode 100644 index 0000000..7bafdbe --- /dev/null +++ b/src/admin/automations/automations-form/automations-rules-form/rule-value-input/index.ts @@ -0,0 +1 @@ +export { RuleValueInput } from "./rule-value-input" diff --git a/src/admin/automations/automations-form/automations-rules-form/rule-value-input/rule-value-input.tsx b/src/admin/automations/automations-form/automations-rules-form/rule-value-input/rule-value-input.tsx new file mode 100644 index 0000000..e79ef3c --- /dev/null +++ b/src/admin/automations/automations-form/automations-rules-form/rule-value-input/rule-value-input.tsx @@ -0,0 +1,99 @@ +import { Input, Label } from "@medusajs/ui" +import { Controller, Control } from "react-hook-form" +import { OperatorType } from "../../../../../modules/mpn-automation/types/types" +import { ChipInput } from "../../../../components/inputs/chip-input" + +type RuleValueInputProps = { + control: Control + name: string + operator: string +} + +export function RuleValueInput({ + control, + name, + operator, +}: RuleValueInputProps) { + const arrayOperators = [ + OperatorType.IN, + OperatorType.NOT_IN, + OperatorType.CONTAINS, + OperatorType.NOT_CONTAINS, + ] + + const noValueOperators = [ + OperatorType.EMPTY, + OperatorType.NOT_EMPTY, + ] + + const isArrayOperator = arrayOperators.includes( + operator as OperatorType + ) + const isNoValueOperator = noValueOperators.includes( + operator as OperatorType + ) + + if (isNoValueOperator) { + return null + } + + return ( + { + if (isArrayOperator) { + const arrayValue = Array.isArray(field.value) + ? field.value + : field.value + ? [String(field.value)] + : [] + + return ( + <> + + + field.onChange(values) + } + onBlur={field.onBlur} + placeholder="Add values (press Enter or comma)" + allowDuplicates={false} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + ) + } + + const stringValue = Array.isArray(field.value) + ? field.value[0] || "" + : (field.value ?? "") + + return ( + <> + + + field.onChange(e.target.value) + } + onBlur={field.onBlur} + ref={field.ref} + placeholder="Enter value" + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + ) + }} + /> + ) +} diff --git a/src/admin/automations/automations-form/types/schema.ts b/src/admin/automations/automations-form/types/schema.ts index dfd1e58..2e6dc6a 100644 --- a/src/admin/automations/automations-form/types/schema.ts +++ b/src/admin/automations/automations-form/types/schema.ts @@ -32,9 +32,23 @@ export const baseAutomationFormSchema = z.object({ .array( z.object({ id: z.string().optional(), + // Value can be string, number, array, or null (for empty/not_empty operators) value: z - .string() - .min(1, "Value is required"), + .union([ + z.string().min(1, "Value is required"), + z.number(), + z.array( + z + .string() + .min( + 1, + "Array values cannot be empty" + ) + ), + z.array(z.number()), + z.null(), + ]) + .optional(), }) ) .optional(), diff --git a/src/admin/automations/automations-form/utils/automation-form-schema.ts b/src/admin/automations/automations-form/utils/automation-form-schema.ts index dc7448d..efa98e9 100644 --- a/src/admin/automations/automations-form/utils/automation-form-schema.ts +++ b/src/admin/automations/automations-form/utils/automation-form-schema.ts @@ -1,5 +1,5 @@ -import { baseAutomationFormSchema } from "../types" import { z } from "zod" +import { baseAutomationFormSchema } from "../types" // Function to create schema with dynamic validation based on availableActions export function createAutomationFormSchema( diff --git a/src/admin/automations/automations-list/automations-list.tsx b/src/admin/automations/automations-list/automations-list.tsx index 0d4bc4f..c71cccc 100644 --- a/src/admin/automations/automations-list/automations-list.tsx +++ b/src/admin/automations/automations-list/automations-list.tsx @@ -47,124 +47,128 @@ export const AutomationsList = () => { // Memoize columns to prevent re-creation on every render // This prevents unmounting of cells when data updates, which would close modals - const columns = useMemo(() => [ - columnHelper.accessor("to", { - header: "Name and description", - cell: ({ row }) => { - const tooltip = `Device (DB) ID: \n ${row?.original?.id}` - return ( - <> -
-
- {row?.original?.name} - - } - maxWidth={400} - > - - + const columns = useMemo( + () => [ + columnHelper.accessor("to", { + header: "Name and description", + cell: ({ row }) => { + const tooltip = `Device (DB) ID: \n ${row?.original?.id}` + return ( + <> +
+
+ {row?.original?.name} + + } + maxWidth={400} + > + + +
+
+ {row?.original?.description} +
-
- {row?.original?.description} -
-
- - ) - }, - }), - columnHelper.accessor("trigger_type", { - header: "Trigger Type", - cell: ({ row }) => { - return {row?.original?.trigger_type} - }, - }), - columnHelper.accessor("event_name", { - header: "Event Name", - cell: ({ row }) => { - return {row?.original?.event_name} - }, - }), - columnHelper.accessor("last_run_at", { - header: "Last Run At", - cell: ({ row }) => { - const lastRunAtAll = row?.original?.states - ?.map((state: any) => state.last_triggered_at) - .sort( - (a: any, b: any) => - new Date(b).getTime() - new Date(a).getTime() + ) - return ( - - {lastRunAtAll.length > 0 - ? new Date(lastRunAtAll[0]).toLocaleString() - : "-"} - - ) - }, - }), - columnHelper.accessor("active", { - header: "Active", - cell: ({ row }) => { - const color = row?.original?.active - ? "green" - : "red" - const text = row?.original?.active ? "Yes" : "No" + }, + }), + columnHelper.accessor("trigger_type", { + header: "Trigger Type", + cell: ({ row }) => { + return {row?.original?.trigger_type} + }, + }), + columnHelper.accessor("event_name", { + header: "Event Name", + cell: ({ row }) => { + return {row?.original?.event_name} + }, + }), + columnHelper.accessor("last_run_at", { + header: "Last Run At", + cell: ({ row }) => { + const lastRunAtAll = row?.original?.states + ?.map((state: any) => state.last_triggered_at) + .sort( + (a: any, b: any) => + new Date(b).getTime() - + new Date(a).getTime() + ) + return ( + + {lastRunAtAll.length > 0 + ? new Date(lastRunAtAll[0]).toLocaleString() + : "-"} + + ) + }, + }), + columnHelper.accessor("active", { + header: "Active", + cell: ({ row }) => { + const color = row?.original?.active + ? "green" + : "red" + const text = row?.original?.active ? "Yes" : "No" - return ( - - {text} - - ) - }, - }), - columnHelper.accessor("created_at", { - header: "Created At", - cell: ({ row }) => { - return ( - - {row?.original?.created_at - ? new Date( - row.original.created_at - ).toLocaleString() - : "-"} - - ) - }, - }), - columnHelper.accessor("updated_at", { - header: "Updated At", - cell: ({ row }) => { - return ( - - {row?.original?.updated_at - ? new Date( - row.original.updated_at - ).toLocaleString() - : "-"} - - ) - }, - }), - columnHelper.accessor("actions", { - header: "Actions", - cell: ({ row }) => { - return ( -
- - -
- ) - }, - }), - ], []) // Empty dependency array - columns don't depend on any props/state + return ( + + {text} + + ) + }, + }), + columnHelper.accessor("created_at", { + header: "Created At", + cell: ({ row }) => { + return ( + + {row?.original?.created_at + ? new Date( + row.original.created_at + ).toLocaleString() + : "-"} + + ) + }, + }), + columnHelper.accessor("updated_at", { + header: "Updated At", + cell: ({ row }) => { + return ( + + {row?.original?.updated_at + ? new Date( + row.original.updated_at + ).toLocaleString() + : "-"} + + ) + }, + }), + columnHelper.accessor("actions", { + header: "Actions", + cell: ({ row }) => { + return ( +
+ + +
+ ) + }, + }), + ], + [] + ) // Empty dependency array - columns don't depend on any props/state const table = useDataTable({ columns, diff --git a/src/admin/utils/dynamic-component.tsx b/src/admin/utils/dynamic-component.tsx index b26a396..71fff49 100644 --- a/src/admin/utils/dynamic-component.tsx +++ b/src/admin/utils/dynamic-component.tsx @@ -17,9 +17,7 @@ export default function LoadActionComponent({ fields?: any }) { const [Component, setComponent] = - useState | null>( - null - ) + useState | null>(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -35,7 +33,7 @@ export default function LoadActionComponent({ loadTemplateComponent(configComponentKey as any) .then((module) => { const Component = module - + if (Component) { setComponent(() => Component as any) } else { diff --git a/src/api/admin/mpn/automations/available-actions/route.ts b/src/api/admin/mpn/automations/available-actions/route.ts index 14922a3..a48ee82 100644 --- a/src/api/admin/mpn/automations/available-actions/route.ts +++ b/src/api/admin/mpn/automations/available-actions/route.ts @@ -14,9 +14,12 @@ export async function GET( ) as MpnAutomationService // Get eventName from query params if provided - const eventName = req.query.eventName as string | undefined + const eventName = req.query.eventName as + | string + | undefined - const actions = automationService.getAvailableActions(eventName) + const actions = + automationService.getAvailableActions(eventName) res.json({ actions: actions, diff --git a/src/api/admin/mpn/automations/rules/route.ts b/src/api/admin/mpn/automations/rules/route.ts index d09f285..d05a2e5 100644 --- a/src/api/admin/mpn/automations/rules/route.ts +++ b/src/api/admin/mpn/automations/rules/route.ts @@ -23,7 +23,16 @@ export const PostAutomationRulesSchema = z.object({ .array( z.object({ id: z.string().optional(), - value: z.string().nullable().optional(), + // Value can be string, number, or array (for array operators like contains, in, etc.) + value: z + .union([ + z.string(), + z.number(), + z.array(z.string()), + z.array(z.number()), + ]) + .nullable() + .optional(), metadata: z .record(z.any()) .nullable() diff --git a/src/modules/mpn-automation/migrations/.snapshot-medusa-mpn-automation.json b/src/modules/mpn-automation/migrations/.snapshot-medusa-mpn-automation.json index bf027ab..b90e4a3 100644 --- a/src/modules/mpn-automation/migrations/.snapshot-medusa-mpn-automation.json +++ b/src/modules/mpn-automation/migrations/.snapshot-medusa-mpn-automation.json @@ -473,12 +473,12 @@ }, "value": { "name": "value", - "type": "text", + "type": "jsonb", "unsigned": false, "autoincrement": false, "primary": false, "nullable": true, - "mappedType": "text" + "mappedType": "json" }, "metadata": { "name": "metadata", diff --git a/src/modules/mpn-automation/migrations/Migration20251217190839.ts b/src/modules/mpn-automation/migrations/Migration20251217190839.ts new file mode 100644 index 0000000..dc3288e --- /dev/null +++ b/src/modules/mpn-automation/migrations/Migration20251217190839.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251217190839 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "mpn_automation_rule_value" alter column "value" type jsonb using ("value"::jsonb);` + ) + } + + override async down(): Promise { + this.addSql( + `alter table if exists "mpn_automation_rule_value" alter column "value" type text using ("value"::text);` + ) + } +} diff --git a/src/modules/mpn-automation/models/mpn_automation_rule_value.ts b/src/modules/mpn-automation/models/mpn_automation_rule_value.ts index 318f984..e70b007 100644 --- a/src/modules/mpn-automation/models/mpn_automation_rule_value.ts +++ b/src/modules/mpn-automation/models/mpn_automation_rule_value.ts @@ -6,7 +6,8 @@ export const MpnAutomationRuleValue = model.define( { id: model.id().primaryKey(), - value: model.text().nullable(), + // Value can be string, number, or array (for array operators like contains) + value: model.json().nullable(), metadata: model.json().nullable(), diff --git a/src/modules/mpn-automation/services/base-action-service.ts b/src/modules/mpn-automation/services/base-action-service.ts index 0c64405..c538195 100644 --- a/src/modules/mpn-automation/services/base-action-service.ts +++ b/src/modules/mpn-automation/services/base-action-service.ts @@ -22,14 +22,18 @@ export class BaseActionService implements ActionHandler { fields: FieldConfig[] = [] // Template registry - each service manages its own templates - protected templates_: Map = new Map() + protected templates_: Map = + new Map() /** * Register a template for this service * @param name - Template name * @param renderer - Template renderer function */ - registerTemplate(name: string, renderer: TemplateRenderer): void { + registerTemplate( + name: string, + renderer: TemplateRenderer + ): void { this.templates_.set(name, renderer) } diff --git a/src/modules/mpn-automation/services/email-action-service.ts b/src/modules/mpn-automation/services/email-action-service.ts index 5b7839e..b0743e5 100644 --- a/src/modules/mpn-automation/services/email-action-service.ts +++ b/src/modules/mpn-automation/services/email-action-service.ts @@ -87,8 +87,17 @@ export class EmailActionService extends BaseActionService { data: TemplateData, options: TemplateOptionsType ) => React.ReactElement - }): Promise<{ html: string; text: string; subject: string }> { - const { templateName, context, options, customTemplateFunction } = params + }): Promise<{ + html: string + text: string + subject: string + }> { + const { + templateName, + context, + options, + customTemplateFunction, + } = params // Use external plugin's renderTemplate function const result = await renderTemplate( diff --git a/src/modules/mpn-automation/services/service.ts b/src/modules/mpn-automation/services/service.ts index 8c225d7..0bd01ce 100644 --- a/src/modules/mpn-automation/services/service.ts +++ b/src/modules/mpn-automation/services/service.ts @@ -37,7 +37,7 @@ import { InviteWorkflowEvents, RegionWorkflowEvents, FulfillmentWorkflowEvents, - PaymentEvents + PaymentEvents, } from "@medusajs/framework/utils" import { getEventMetadata } from "../types/modules" @@ -80,13 +80,15 @@ class MpnAutomationService extends MedusaService({ // Initialize extended actions (custom handlers and templates) // Note: templates with import paths will be loaded asynchronously this.initializeExtendedActions().catch((error) => { - this.logger_.error(`Failed to initialize extended actions: ${error?.message || "Unknown error"}`) + this.logger_.error( + `Failed to initialize extended actions: ${error?.message || "Unknown error"}` + ) }) } /** * Get available triggers for the admin panel form - * + * * @returns Array of triggers */ getAvailableTriggers() { @@ -95,7 +97,7 @@ class MpnAutomationService extends MedusaService({ /** * Get action handlers map - * + * * @returns Map of action handlers */ private getActionHandlers(): Map< @@ -108,7 +110,7 @@ class MpnAutomationService extends MedusaService({ /** * Build events list using central metadata registry * Supports both Medusa events and custom events - * + * * @returns Array of events */ buildAvailableEvents() { @@ -144,19 +146,27 @@ class MpnAutomationService extends MedusaService({ }, { name: "Sales Channel", - events: this.buildEvents(SalesChannelWorkflowEvents), + events: this.buildEvents( + SalesChannelWorkflowEvents + ), }, { name: "Product Category", - events: this.buildEvents(ProductCategoryWorkflowEvents), + events: this.buildEvents( + ProductCategoryWorkflowEvents + ), }, { name: "Product Collection", - events: this.buildEvents(ProductCollectionWorkflowEvents), + events: this.buildEvents( + ProductCollectionWorkflowEvents + ), }, { name: "Product Variant", - events: this.buildEvents(ProductVariantWorkflowEvents), + events: this.buildEvents( + ProductVariantWorkflowEvents + ), }, { name: "Product", @@ -172,7 +182,9 @@ class MpnAutomationService extends MedusaService({ }, { name: "Product Option", - events: this.buildEvents(ProductOptionWorkflowEvents), + events: this.buildEvents( + ProductOptionWorkflowEvents + ), }, { name: "Invite", @@ -194,9 +206,15 @@ class MpnAutomationService extends MedusaService({ // Filter out empty groups and ensure all groups have events array return events - .filter((group) => group && group.events && Array.isArray(group.events) && group.events.length > 0) + .filter( + (group) => + group && + group.events && + Array.isArray(group.events) && + group.events.length > 0 + ) .map((group) => ({ - name: String(group.name || ''), + name: String(group.name || ""), events: group.events || [], })) } @@ -204,12 +222,12 @@ class MpnAutomationService extends MedusaService({ /** * Get available events for the admin panel form * Combines Medusa events with custom events - * + * * @returns Array of events */ getAvailableEvents() { const medusaEvents = this.buildAvailableEvents() - + if (!this.events_ || this.events_.length === 0) { return medusaEvents } @@ -225,20 +243,24 @@ class MpnAutomationService extends MedusaService({ /** * Get available templates for a given event name * Uses getAvailableEvents() to find the event and extract template - * + * * @param eventName - Event name to search for * @returns Array of template options */ - getTemplatesForEvent(eventName?: string): Array<{ value: string; name: string }> { + getTemplatesForEvent( + eventName?: string + ): Array<{ value: string; name: string }> { if (!eventName) { return [] } const allEvents = this.getAvailableEvents() - + // Search through all event groups for (const group of allEvents) { - const event = group.events?.find((e: any) => e.value === eventName) + const event = group.events?.find( + (e: any) => e.value === eventName + ) if (event?.templates && event.templates.length > 0) { return event.templates } @@ -249,7 +271,7 @@ class MpnAutomationService extends MedusaService({ /** * Initialize action handlers from defaults and options - * + * * @returns void */ private initializeActionHandlers() { @@ -275,18 +297,20 @@ class MpnAutomationService extends MedusaService({ /** * Initialize extended actions (custom handlers and templates) * Handles both custom handler registration and template loading - * + * * @returns Promise */ private async initializeExtendedActions(): Promise { - const extendedActions = this.options_.automations?.extend?.actions || [] - + const extendedActions = + this.options_.automations?.extend?.actions || [] + await Promise.all( extendedActions.map(async (actionConfig: any) => { // 1. Register custom handler if provided if (actionConfig.handler) { if (!this.actionHandlers_.has(actionConfig.id)) { - const isEnabled = this.actionsEnabled_[actionConfig.id] + const isEnabled = + this.actionsEnabled_[actionConfig.id] this.actionHandlers_.set(actionConfig.id, { handler: actionConfig.handler, @@ -304,9 +328,14 @@ class MpnAutomationService extends MedusaService({ } // 2. Register templates (for existing or newly registered handler) - if (actionConfig.templates && Array.isArray(actionConfig.templates)) { - const handlerData = this.getActionHandler(actionConfig.id) - + if ( + actionConfig.templates && + Array.isArray(actionConfig.templates) + ) { + const handlerData = this.getActionHandler( + actionConfig.id + ) + if (!handlerData) { this.logger_.warn( `Cannot register templates for "${actionConfig.id}" - handler not found` @@ -324,38 +353,45 @@ class MpnAutomationService extends MedusaService({ } await Promise.all( - actionConfig.templates.map(async (template: any) => { - const templateName = template.name - const templateValue = template.path + actionConfig.templates.map( + async (template: any) => { + const templateName = template.name + const templateValue = template.path - let renderer = templateValue + let renderer = templateValue - try { - const templateModule = await import(templateValue) - const template = templateModule.default - renderer = template?.default || template - - if (!renderer) { + try { + const templateModule = await import( + templateValue + ) + const template = templateModule.default + renderer = template?.default || template + + if (!renderer) { + this.logger_.warn( + `Template module from "${templateValue}" does not export a default function or expected named export` + ) + return + } + } catch (error: any) { this.logger_.warn( - `Template module from "${templateValue}" does not export a default function or expected named export` + `Failed to load template from "${templateValue}": ${error?.message || "Unknown error"}` ) return } - } catch (error: any) { - this.logger_.warn( - `Failed to load template from "${templateValue}": ${error?.message || "Unknown error"}` - ) - return - } - if (templateName) { - handler.registerTemplate!(templateName, renderer) + if (templateName) { + handler.registerTemplate!( + templateName, + renderer + ) - this.logger_.info( - `Custom template "${templateName}" registered for handler "${actionConfig.id}"` - ) + this.logger_.info( + `Custom template "${templateName}" registered for handler "${actionConfig.id}"` + ) + } } - }) + ) ) } }) @@ -365,7 +401,7 @@ class MpnAutomationService extends MedusaService({ /** * Get available actions for the admin panel form * If Handler has fields, we can push templateName field to fields array, then in the admin panel form we can render the templateName field as a select field with the templates options. - * + * * @param eventName - Optional event name to filter templates dynamically * @returns Array of actions */ @@ -378,15 +414,25 @@ class MpnAutomationService extends MedusaService({ // If eventName is provided, update templateName fields dynamically if (eventName && fields.length > 0) { - const templates = this.getTemplatesForEvent(eventName) - + const templates = + this.getTemplatesForEvent(eventName) + fields = fields.map((field) => { // If this is a templateName field, update its options - if (field.key === "templateName" && field.type === "select") { + if ( + field.key === "templateName" && + field.type === "select" + ) { return { ...field, - options: templates.length > 0 ? templates : field.options || [], - defaultValue: templates.length > 0 ? templates[0]?.value : field.defaultValue, + options: + templates.length > 0 + ? templates + : field.options || [], + defaultValue: + templates.length > 0 + ? templates[0]?.value + : field.defaultValue, } } return field @@ -397,7 +443,8 @@ class MpnAutomationService extends MedusaService({ value: handler.handler.id, label: handler.handler.label, description: handler.handler.description, - configComponentKey: handler.handler.configComponentKey, + configComponentKey: + handler.handler.configComponentKey, templateLoaders: handler.handler.templateLoaders, fields: fields, enabled: handler.enabled, @@ -408,32 +455,38 @@ class MpnAutomationService extends MedusaService({ /** * Build events list from Medusa events * Supports both Medusa events and custom events - * + * * @param events - Medusa events object * @returns Array of events */ private buildEvents(events: any) { - if (!events || typeof events !== 'object') { + if (!events || typeof events !== "object") { return [] } - + return Object.values(events) .filter((event: any) => event != null) // Filter out null/undefined .map((event: any) => { const eventName = String(event) - + // Skip invalid event names - if (!eventName || eventName === 'undefined' || eventName === 'null') { + if ( + !eventName || + eventName === "undefined" || + eventName === "null" + ) { return null } const metadata = getEventMetadata(eventName) - + return { value: eventName, label: eventName, - attributes: metadata.attributes || event.attributes || [], - templates: metadata.templates || event.templates || [], + attributes: + metadata.attributes || event.attributes || [], + templates: + metadata.templates || event.templates || [], contextType: event.contextType || null, // Only from custom events, not from registry } }) @@ -443,7 +496,7 @@ class MpnAutomationService extends MedusaService({ /** * Get action handler by ID for the admin panel form * Used to get the action handler by ID in the Run Automation Actions workflow step - * + * * @param actionId - Action ID * @returns Action handler */ diff --git a/src/modules/mpn-automation/services/slack-action-service.ts b/src/modules/mpn-automation/services/slack-action-service.ts index a716955..9ad8566 100644 --- a/src/modules/mpn-automation/services/slack-action-service.ts +++ b/src/modules/mpn-automation/services/slack-action-service.ts @@ -1,5 +1,8 @@ import { BaseActionService } from "./base-action-service" -import { SlackTemplateRenderer, SlackBlock } from "../../../templates/slack/types" +import { + SlackTemplateRenderer, + SlackBlock, +} from "../../../templates/slack/types" import { renderInventoryLevel } from "../../../templates/slack/inventory-level" import { renderProductVariant } from "../../../templates/slack/product-variant/product-variant" import { renderProduct } from "../../../templates/slack/product/product" @@ -15,7 +18,7 @@ export class SlackActionService extends BaseActionService { fields = [ // Add templateName field - options will be populated dynamically by service based on eventName - this.addTemplateNameField() + this.addTemplateNameField(), ] /** @@ -23,8 +26,14 @@ export class SlackActionService extends BaseActionService { */ protected initializeTemplates(): void { // Register default templates - this.registerTemplate("inventory-level", renderInventoryLevel as any) - this.registerTemplate("product-variant", renderProductVariant as any) + this.registerTemplate( + "inventory-level", + renderInventoryLevel as any + ) + this.registerTemplate( + "product-variant", + renderProductVariant as any + ) this.registerTemplate("product", renderProduct as any) } @@ -39,7 +48,9 @@ export class SlackActionService extends BaseActionService { contextType?: string | null options?: any }): Promise<{ text: string; blocks: SlackBlock[] }> { - const renderer = this.getTemplate(params.templateName) as SlackTemplateRenderer | undefined + const renderer = this.getTemplate( + params.templateName + ) as SlackTemplateRenderer | undefined if (!renderer) { throw new Error( diff --git a/src/modules/mpn-automation/types/action-handler.ts b/src/modules/mpn-automation/types/action-handler.ts index cb40f84..7feeb2b 100644 --- a/src/modules/mpn-automation/types/action-handler.ts +++ b/src/modules/mpn-automation/types/action-handler.ts @@ -65,14 +65,19 @@ export interface ActionHandler { * @param name - Template name * @param renderer - Template renderer function */ - registerTemplate?: (name: string, renderer: TemplateRenderer) => void + registerTemplate?: ( + name: string, + renderer: TemplateRenderer + ) => void /** * Get template renderer by name * @param name - Template name * @returns Template renderer or undefined */ - getTemplate?: (name: string) => TemplateRenderer | undefined + getTemplate?: ( + name: string + ) => TemplateRenderer | undefined /** * Render template (wrapper method) diff --git a/src/modules/mpn-automation/types/index.ts b/src/modules/mpn-automation/types/index.ts index 899a79a..23490ab 100644 --- a/src/modules/mpn-automation/types/index.ts +++ b/src/modules/mpn-automation/types/index.ts @@ -1,4 +1,4 @@ export * from "./types" export * from "./interfaces" export * from "./action-handler" -export * from "./modules" \ No newline at end of file +export * from "./modules" diff --git a/src/modules/mpn-automation/types/interfaces.ts b/src/modules/mpn-automation/types/interfaces.ts index 50da154..bd97c67 100644 --- a/src/modules/mpn-automation/types/interfaces.ts +++ b/src/modules/mpn-automation/types/interfaces.ts @@ -2,7 +2,8 @@ import { TriggerType } from "./types" export interface AutomationRuleValue { id?: string - value: string | null + // Value can be string, number, or array (for array operators like contains, in, etc.) + value: string | number | string[] | number[] | null metadata: Record | null } diff --git a/src/modules/mpn-automation/types/modules/index.ts b/src/modules/mpn-automation/types/modules/index.ts index 29eb6bb..b661c8a 100644 --- a/src/modules/mpn-automation/types/modules/index.ts +++ b/src/modules/mpn-automation/types/modules/index.ts @@ -4,6 +4,9 @@ import { } from "./inventory" import { PRODUCT_ATTRIBUTES } from "./product" import { PRODUCT_VARIANT_ATTRIBUTES } from "./product-variant" +import { PRODUCT_TAG_ATTRIBUTES } from "./product-tag" +import { PRODUCT_TYPE_ATTRIBUTES } from "./product-type" +import { PRODUCT_CATEGORY_ATTRIBUTES } from "./product-category" import { Attribute } from "../types" /** @@ -32,10 +35,7 @@ export function getEventMetadata( * - Map events to template names (multiple templates per event) * - Support both Medusa events and custom events */ -const EVENT_METADATA_REGISTRY: Record< - string, - any -> = { +const EVENT_METADATA_REGISTRY: Record = { // Inventory Events "inventory.inventory-level.created": { attributes: INVENTORY_LEVEL_ATTRIBUTES, @@ -91,6 +91,15 @@ const EVENT_METADATA_REGISTRY: Record< }, ], }, + "product.updated": { + attributes: PRODUCT_ATTRIBUTES, + templates: [ + { + value: "product", + name: "Product", + }, + ], + }, "product-variant.updated": { attributes: PRODUCT_VARIANT_ATTRIBUTES, templates: [ @@ -100,12 +109,30 @@ const EVENT_METADATA_REGISTRY: Record< }, ], }, - "product.updated": { - attributes: PRODUCT_ATTRIBUTES, + "product-tag.updated": { + attributes: PRODUCT_TAG_ATTRIBUTES, templates: [ { - value: "product", - name: "Product", + value: "product-tag", + name: "Product Tag", + }, + ], + }, + "product-type.updated": { + attributes: PRODUCT_TYPE_ATTRIBUTES, + templates: [ + { + value: "product-type", + name: "Product Type", + }, + ], + }, + "product-category.updated": { + attributes: PRODUCT_CATEGORY_ATTRIBUTES, + templates: [ + { + value: "product-category", + name: "Product Category", }, ], }, diff --git a/src/modules/mpn-automation/types/modules/inventory/index.ts b/src/modules/mpn-automation/types/modules/inventory/index.ts index 0de7d2a..d758973 100644 --- a/src/modules/mpn-automation/types/modules/inventory/index.ts +++ b/src/modules/mpn-automation/types/modules/inventory/index.ts @@ -1 +1 @@ -export * from "./inventory" \ No newline at end of file +export * from "./inventory" diff --git a/src/modules/mpn-automation/types/modules/inventory/inventory.ts b/src/modules/mpn-automation/types/modules/inventory/inventory.ts index eb8ed5b..ee19a31 100644 --- a/src/modules/mpn-automation/types/modules/inventory/inventory.ts +++ b/src/modules/mpn-automation/types/modules/inventory/inventory.ts @@ -1,4 +1,3 @@ - export const INVENTORY_ITEM_ATTRIBUTES = [ { value: "inventory_item.stocked_quantity", @@ -51,4 +50,18 @@ export const INVENTORY_LEVEL_ATTRIBUTES = [ value: "inventory_level.location_id", label: "Location ID", }, -] \ No newline at end of file + { + value: "inventory_level.stock_locations.id", + label: "Stock Location ID", + type: "array", + isRelation: true, + relationType: "stock_locations", + }, + { + value: "inventory_level.stock_locations.name", + label: "Stock Location Name", + type: "array", + isRelation: true, + relationType: "stock_locations", + }, +] diff --git a/src/modules/mpn-automation/types/modules/product-category/index.ts b/src/modules/mpn-automation/types/modules/product-category/index.ts new file mode 100644 index 0000000..96720aa --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-category/index.ts @@ -0,0 +1 @@ +export * from "./product-category" diff --git a/src/modules/mpn-automation/types/modules/product-category/product-category.ts b/src/modules/mpn-automation/types/modules/product-category/product-category.ts new file mode 100644 index 0000000..977a8ec --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-category/product-category.ts @@ -0,0 +1,42 @@ +export const PRODUCT_CATEGORY_ATTRIBUTES = [ + { + value: "product_category.id", + label: "ID", + }, + { + value: "product_category.name", + label: "Name", + }, + { + value: "product_category.description", + label: "Description", + }, + { + value: "product_category.handle", + label: "Handle", + }, + { + value: "product_category.is_active", + label: "Is Active", + }, + { + value: "product_category.is_internal", + label: "Is Internal", + }, + { + value: "product_category.rank", + label: "Rank", + }, + { + value: "product_category.parent_category_id", + label: "Parent Category ID", + }, + { + value: "product_category.created_at", + label: "Created At", + }, + { + value: "product_category.updated_at", + label: "Updated At", + }, +] diff --git a/src/modules/mpn-automation/types/modules/product-tag/index.ts b/src/modules/mpn-automation/types/modules/product-tag/index.ts new file mode 100644 index 0000000..a12a59e --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-tag/index.ts @@ -0,0 +1 @@ +export * from "./product-tag" diff --git a/src/modules/mpn-automation/types/modules/product-tag/product-tag.ts b/src/modules/mpn-automation/types/modules/product-tag/product-tag.ts new file mode 100644 index 0000000..830a46f --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-tag/product-tag.ts @@ -0,0 +1,18 @@ +export const PRODUCT_TAG_ATTRIBUTES = [ + { + value: "product_tag.id", + label: "ID", + }, + { + value: "product_tag.value", + label: "Value", + }, + { + value: "product_tag.created_at", + label: "Created At", + }, + { + value: "product_tag.updated_at", + label: "Updated At", + }, +] diff --git a/src/modules/mpn-automation/types/modules/product-type/index.ts b/src/modules/mpn-automation/types/modules/product-type/index.ts new file mode 100644 index 0000000..a1e5933 --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-type/index.ts @@ -0,0 +1 @@ +export * from "./product-type" diff --git a/src/modules/mpn-automation/types/modules/product-type/product-type.ts b/src/modules/mpn-automation/types/modules/product-type/product-type.ts new file mode 100644 index 0000000..4811aba --- /dev/null +++ b/src/modules/mpn-automation/types/modules/product-type/product-type.ts @@ -0,0 +1,18 @@ +export const PRODUCT_TYPE_ATTRIBUTES = [ + { + value: "product_type.id", + label: "ID", + }, + { + value: "product_type.value", + label: "Value", + }, + { + value: "product_type.created_at", + label: "Created At", + }, + { + value: "product_type.updated_at", + label: "Updated At", + }, +] diff --git a/src/modules/mpn-automation/types/modules/product-variant/index.ts b/src/modules/mpn-automation/types/modules/product-variant/index.ts index d713753..dd21220 100644 --- a/src/modules/mpn-automation/types/modules/product-variant/index.ts +++ b/src/modules/mpn-automation/types/modules/product-variant/index.ts @@ -1 +1 @@ -export * from "./product-variant" \ No newline at end of file +export * from "./product-variant" diff --git a/src/modules/mpn-automation/types/modules/product-variant/product-variant.ts b/src/modules/mpn-automation/types/modules/product-variant/product-variant.ts index 0305944..2d7dadd 100644 --- a/src/modules/mpn-automation/types/modules/product-variant/product-variant.ts +++ b/src/modules/mpn-automation/types/modules/product-variant/product-variant.ts @@ -23,4 +23,64 @@ export const PRODUCT_VARIANT_ATTRIBUTES = [ value: "product_variant.upc", label: "UPC", }, -] \ No newline at end of file + { + value: "product_variant.allow_backorder", + label: "Allow Backorder", + }, + { + value: "product_variant.manage_inventory", + label: "Manage Inventory", + }, + { + value: "product_variant.hs_code", + label: "HS Code", + }, + { + value: "product_variant.origin_country", + label: "Origin Country", + }, + { + value: "product_variant.mid_code", + label: "MID Code", + }, + { + value: "product_variant.material", + label: "Material", + }, + { + value: "product_variant.weight", + label: "Weight", + }, + { + value: "product_variant.length", + label: "Length", + }, + { + value: "product_variant.height", + label: "Height", + }, + { + value: "product_variant.width", + label: "Width", + }, + // { + // value: "product_variant.metadata", + // label: "Metadata", + // }, + { + value: "product_variant.variant_rank", + label: "Variant Rank", + }, + { + value: "product_variant.product_id", + label: "Product ID", + }, + { + value: "product_variant.created_at", + label: "Created At", + }, + { + value: "product_variant.updated_at", + label: "Updated At", + }, +] diff --git a/src/modules/mpn-automation/types/modules/product/index.ts b/src/modules/mpn-automation/types/modules/product/index.ts index c41e29e..f7a5bca 100644 --- a/src/modules/mpn-automation/types/modules/product/index.ts +++ b/src/modules/mpn-automation/types/modules/product/index.ts @@ -1 +1 @@ -export * from "./product" \ No newline at end of file +export * from "./product" diff --git a/src/modules/mpn-automation/types/modules/product/product.ts b/src/modules/mpn-automation/types/modules/product/product.ts index 38f770c..38d8209 100644 --- a/src/modules/mpn-automation/types/modules/product/product.ts +++ b/src/modules/mpn-automation/types/modules/product/product.ts @@ -43,4 +43,143 @@ export const PRODUCT_ATTRIBUTES = [ value: "product.upc", label: "UPC", }, -] \ No newline at end of file + { + value: "product.thumbnail", + label: "Thumbnail", + }, + { + value: "product.hs_code", + label: "HS Code", + }, + { + value: "product.origin_country", + label: "Origin Country", + }, + { + value: "product.mid_code", + label: "MID Code", + }, + { + value: "product.material", + label: "Material", + }, + { + value: "product.weight", + label: "Weight", + }, + { + value: "product.length", + label: "Length", + }, + { + value: "product.height", + label: "Height", + }, + { + value: "product.width", + label: "Width", + }, + { + value: "product.created_at", + label: "Created At", + }, + { + value: "product.updated_at", + label: "Updated At", + }, + { + value: "product.deleted_at", + label: "Deleted At", + }, + { + value: "product.tags.id", + label: "Tag ID", + type: "array", + isRelation: true, + relationType: "tags", + }, + { + value: "product.tags.value", + label: "Tag Value", + type: "array", + isRelation: true, + relationType: "tags", + }, + { + value: "product.categories.id", + label: "Category ID", + type: "array", + isRelation: true, + relationType: "categories", + }, + { + value: "product.categories.name", + label: "Category Name", + type: "array", + isRelation: true, + relationType: "categories", + }, + { + value: "product.categories.handle", + label: "Category Handle", + type: "array", + isRelation: true, + relationType: "categories", + }, + { + value: "product.variants.id", + label: "Variant ID", + type: "array", + isRelation: true, + relationType: "variants", + }, + { + value: "product.variants.sku", + label: "Variant SKU", + type: "array", + isRelation: true, + relationType: "variants", + }, + { + value: "product.variants.title", + label: "Variant Title", + type: "array", + isRelation: true, + relationType: "variants", + }, + { + value: "product.type.id", + label: "Type ID", + type: "object", + isRelation: true, + relationType: "type", + }, + { + value: "product.type.value", + label: "Type Value", + type: "object", + isRelation: true, + relationType: "type", + }, + { + value: "product.collection.id", + label: "Collection ID", + type: "object", + isRelation: true, + relationType: "collection", + }, + { + value: "product.collection.title", + label: "Collection Title", + type: "object", + isRelation: true, + relationType: "collection", + }, + { + value: "product.collection.handle", + label: "Collection Handle", + type: "object", + isRelation: true, + relationType: "collection", + }, +] diff --git a/src/modules/mpn-automation/types/types.ts b/src/modules/mpn-automation/types/types.ts index 728415b..8640d1f 100644 --- a/src/modules/mpn-automation/types/types.ts +++ b/src/modules/mpn-automation/types/types.ts @@ -43,6 +43,21 @@ export type CustomAction = { export type Attribute = { value?: string label?: string + /** + * Type of the attribute value + * - "primitive": single value (string, number, boolean) + * - "array": array of values (e.g., tags, categories) + * - "object": nested object + */ + type?: "primitive" | "array" | "object" + /** + * Whether this attribute represents a relation + */ + isRelation?: boolean + /** + * Type of relation (e.g., "tags", "categories", "variants") + */ + relationType?: string } export interface FieldConfig { @@ -171,11 +186,17 @@ export enum TriggerType { export enum OperatorType { EQUAL = "eq", - NOT_EQUAL = "neq", + NOT_EQUAL = "ne", + IN = "in", + NOT_IN = "nin", GREATER_THAN = "gt", LESS_THAN = "lt", GREATER_THAN_OR_EQUAL = "gte", LESS_THAN_OR_EQUAL = "lte", + CONTAINS = "contains", + NOT_CONTAINS = "not_contains", + EMPTY = "empty", + NOT_EMPTY = "not_empty", } export const OPERATOR_TYPES = [ @@ -187,6 +208,14 @@ export const OPERATOR_TYPES = [ value: OperatorType.NOT_EQUAL, label: "Not Equal", }, + { + value: OperatorType.IN, + label: "In", + }, + { + value: OperatorType.NOT_IN, + label: "Not In", + }, { value: OperatorType.GREATER_THAN, label: "Greater Than", @@ -203,6 +232,22 @@ export const OPERATOR_TYPES = [ value: OperatorType.LESS_THAN_OR_EQUAL, label: "Less Than or Equal", }, + { + value: OperatorType.CONTAINS, + label: "Contains", + }, + { + value: OperatorType.NOT_CONTAINS, + label: "Not Contains", + }, + { + value: OperatorType.EMPTY, + label: "Empty", + }, + { + value: OperatorType.NOT_EMPTY, + label: "Not Empty", + }, ] export const TRIGGER_TYPES = [ @@ -218,4 +263,4 @@ export const TRIGGER_TYPES = [ value: TriggerType.MANUAL, label: "Manual", }, -] \ No newline at end of file +] diff --git a/src/providers/slack/service.ts b/src/providers/slack/service.ts index a4ac229..dbf3619 100644 --- a/src/providers/slack/service.ts +++ b/src/providers/slack/service.ts @@ -58,7 +58,9 @@ export class SlackNotificationProviderService extends AbstractNotificationProvid } async send( - notification: NotificationTypes.ProviderSendNotificationDTO & { content: any } + notification: NotificationTypes.ProviderSendNotificationDTO & { + content: any + } ): Promise { const { template, data, content } = notification as { template: string diff --git a/src/subscribers/inventory-reservation-item-updated.ts b/src/subscribers/inventory-reservation-item-updated.ts index 2a8188c..b570149 100644 --- a/src/subscribers/inventory-reservation-item-updated.ts +++ b/src/subscribers/inventory-reservation-item-updated.ts @@ -22,8 +22,8 @@ export default async function inventoryReservationItemUpdatedHandler({ // ) // const query = container.resolve(ContainerRegistrationKeys.QUERY) // const triggerType = trigger_type || 'system' - console.log("inventoryReservationItemUpdatedHandler"); - console.log(data); + console.log("inventoryReservationItemUpdatedHandler") + console.log(data) } export const config: SubscriberConfig = { diff --git a/src/subscribers/mpn.automation.action.email.executed.ts b/src/subscribers/mpn.automation.action.email.executed.ts index fc0c4aa..55032bf 100644 --- a/src/subscribers/mpn.automation.action.email.executed.ts +++ b/src/subscribers/mpn.automation.action.email.executed.ts @@ -22,7 +22,12 @@ export default async function mpnAutomationActionEmailExecutedHandler({ event: { data }, container, }: SubscriberArgs) { - const { action, context, eventName: triggerEventName, contextType } = data + const { + action, + context, + eventName: triggerEventName, + contextType, + } = data console.log(eventName, data) @@ -44,7 +49,7 @@ export default async function mpnAutomationActionEmailExecutedHandler({ }, context: context, contextType: contextType, - eventName: triggerEventName + eventName: triggerEventName, }, }) diff --git a/src/subscribers/mpn.automation.action.slack.executed.ts b/src/subscribers/mpn.automation.action.slack.executed.ts index 8b962fd..4cd3cbc 100644 --- a/src/subscribers/mpn.automation.action.slack.executed.ts +++ b/src/subscribers/mpn.automation.action.slack.executed.ts @@ -22,7 +22,12 @@ export default async function mpnAutomationActionSlackExecutedHandler({ event: { data }, container, }: SubscriberArgs) { - const { action, context, eventName: triggerEventName, contextType } = data + const { + action, + context, + eventName: triggerEventName, + contextType, + } = data const config = container.resolve("configModule") as any const moduleConfig = config?.modules.mpnAutomation const backendUrl = moduleConfig?.options.backend_url @@ -36,14 +41,13 @@ export default async function mpnAutomationActionSlackExecutedHandler({ ...action, config: { ...action.config, - template: - action.config.templateName, - backendUrl: backendUrl + template: action.config.templateName, + backendUrl: backendUrl, }, }, context: context, contextType: contextType, - eventName: triggerEventName + eventName: triggerEventName, }, }) diff --git a/src/subscribers/order-placed.ts b/src/subscribers/order-placed.ts index da4087b..acedb75 100644 --- a/src/subscribers/order-placed.ts +++ b/src/subscribers/order-placed.ts @@ -7,8 +7,10 @@ export default async function orderPlacedHandler({ event: { data }, container, }: SubscriberArgs) { - - console.log("orderPlacedHandler", JSON.stringify(data, null, 2)) + console.log( + "orderPlacedHandler", + JSON.stringify(data, null, 2) + ) } export const config: SubscriberConfig = { diff --git a/src/subscribers/product-updated.ts b/src/subscribers/product-updated.ts index 4588e2f..aab86e9 100644 --- a/src/subscribers/product-updated.ts +++ b/src/subscribers/product-updated.ts @@ -36,6 +36,8 @@ export default async function productUpdatedHandler({ product: product, } + console.log(contextData) + // Run automation workflow - this will: // 1. Retrieve triggers for the event // 2. Validate triggers against context diff --git a/src/templates/slack/inventory-level/index.ts b/src/templates/slack/inventory-level/index.ts index 9e4f16e..df1fa45 100644 --- a/src/templates/slack/inventory-level/index.ts +++ b/src/templates/slack/inventory-level/index.ts @@ -1 +1 @@ -export * from "./inventory-level" \ No newline at end of file +export * from "./inventory-level" diff --git a/src/templates/slack/inventory-level/inventory-level.ts b/src/templates/slack/inventory-level/inventory-level.ts index 881ca69..b4e84b5 100644 --- a/src/templates/slack/inventory-level/inventory-level.ts +++ b/src/templates/slack/inventory-level/inventory-level.ts @@ -1,7 +1,4 @@ -import { - SlackTemplateOptions, - SlackBlock, -} from "../types" +import { SlackTemplateOptions, SlackBlock } from "../types" import { translations } from "./translations" import { createTranslator, @@ -18,7 +15,10 @@ export function renderInventoryLevel({ context, contextType, options = {}, -}: RenderInventoryLevelParams): { text: string; blocks: SlackBlock[] } { +}: RenderInventoryLevelParams): { + text: string + blocks: SlackBlock[] +} { const backendUrl = options?.backendUrl || "" const locale = options?.locale || "pl" const inventoryLevel = context?.inventory_level diff --git a/src/templates/slack/inventory-level/translations/index.ts b/src/templates/slack/inventory-level/translations/index.ts index 62390cc..6bbe4d8 100644 --- a/src/templates/slack/inventory-level/translations/index.ts +++ b/src/templates/slack/inventory-level/translations/index.ts @@ -1,10 +1,9 @@ -import pl from "./pl.json"; -import en from "./en.json"; +import pl from "./pl.json" +import en from "./en.json" export const translations: Record = { pl: pl, en: en, -}; - -export { pl, en }; +} +export { pl, en } diff --git a/src/templates/slack/product-variant/index.ts b/src/templates/slack/product-variant/index.ts index d713753..dd21220 100644 --- a/src/templates/slack/product-variant/index.ts +++ b/src/templates/slack/product-variant/index.ts @@ -1 +1 @@ -export * from "./product-variant" \ No newline at end of file +export * from "./product-variant" diff --git a/src/templates/slack/product-variant/product-variant.ts b/src/templates/slack/product-variant/product-variant.ts index 8aeb19d..288ae35 100644 --- a/src/templates/slack/product-variant/product-variant.ts +++ b/src/templates/slack/product-variant/product-variant.ts @@ -1,7 +1,4 @@ -import { - SlackTemplateOptions, - SlackBlock, -} from "../types" +import { SlackTemplateOptions, SlackBlock } from "../types" import { translations } from "./translations" import { createTranslator, @@ -18,7 +15,10 @@ export function renderProductVariant({ context, contextType, options = {}, -}: RenderProductVariantParams): { text: string; blocks: SlackBlock[] } { +}: RenderProductVariantParams): { + text: string + blocks: SlackBlock[] +} { const backendUrl = options?.backendUrl || "" const locale = options?.locale || "pl" const productVariant = context?.product_variant @@ -44,8 +44,7 @@ export function renderProductVariant({ type: "plain_text", text: t("header.title", { productVariantTitle: - productVariant?.title || - "unknown", + productVariant?.title || "unknown", }), emoji: true, }, diff --git a/src/templates/slack/product-variant/translations/index.ts b/src/templates/slack/product-variant/translations/index.ts index 62390cc..6bbe4d8 100644 --- a/src/templates/slack/product-variant/translations/index.ts +++ b/src/templates/slack/product-variant/translations/index.ts @@ -1,10 +1,9 @@ -import pl from "./pl.json"; -import en from "./en.json"; +import pl from "./pl.json" +import en from "./en.json" export const translations: Record = { pl: pl, en: en, -}; - -export { pl, en }; +} +export { pl, en } diff --git a/src/templates/slack/product/index.ts b/src/templates/slack/product/index.ts index c41e29e..f7a5bca 100644 --- a/src/templates/slack/product/index.ts +++ b/src/templates/slack/product/index.ts @@ -1 +1 @@ -export * from "./product" \ No newline at end of file +export * from "./product" diff --git a/src/templates/slack/product/product.ts b/src/templates/slack/product/product.ts index 221e2c3..4d3d528 100644 --- a/src/templates/slack/product/product.ts +++ b/src/templates/slack/product/product.ts @@ -1,7 +1,4 @@ -import { - SlackTemplateOptions, - SlackBlock, -} from "../types" +import { SlackTemplateOptions, SlackBlock } from "../types" import { translations } from "./translations" import { createTranslator, @@ -18,7 +15,10 @@ export function renderProduct({ context, contextType, options = {}, -}: RenderProductParams): { text: string; blocks: SlackBlock[] } { +}: RenderProductParams): { + text: string + blocks: SlackBlock[] +} { const backendUrl = options?.backendUrl || "" const locale = options?.locale || "pl" const product = context?.product @@ -43,9 +43,7 @@ export function renderProduct({ text: { type: "plain_text", text: t("header.title", { - productTitle: - product?.title || - "unknown", + productTitle: product?.title || "unknown", }), emoji: true, }, diff --git a/src/templates/slack/product/translations/index.ts b/src/templates/slack/product/translations/index.ts index 62390cc..6bbe4d8 100644 --- a/src/templates/slack/product/translations/index.ts +++ b/src/templates/slack/product/translations/index.ts @@ -1,10 +1,9 @@ -import pl from "./pl.json"; -import en from "./en.json"; +import pl from "./pl.json" +import en from "./en.json" export const translations: Record = { pl: pl, en: en, -}; - -export { pl, en }; +} +export { pl, en } diff --git a/src/utils/attribute-helpers.ts b/src/utils/attribute-helpers.ts new file mode 100644 index 0000000..53652c2 --- /dev/null +++ b/src/utils/attribute-helpers.ts @@ -0,0 +1,18 @@ +/** + * Extract fields from attributes for use in query.graph() + * Converts "product.tags.value" -> "tags.value" + */ +export function getFieldsFromAttributes( + attributes: Array<{ value?: string }>, + entityPrefix: string = "product" +): string[] { + return attributes + .map((attr) => attr.value) + .filter((value): value is string => !!value) + .map((value) => + value.startsWith(`${entityPrefix}.`) + ? value.slice(entityPrefix.length + 1) + : value + ) + .sort() +} diff --git a/src/utils/i18n/i18n.ts b/src/utils/i18n/i18n.ts index 4b12e09..37ee6d8 100644 --- a/src/utils/i18n/i18n.ts +++ b/src/utils/i18n/i18n.ts @@ -6,72 +6,82 @@ /** * Check if value is a plain object (not array, null, etc.) */ -function isObject(value: any): value is Record { +function isObject( + value: any +): value is Record { return ( value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp) - ); + ) } /** * Flatten translations structure - extracts 'general' object properties to root level * This allows JSON files to have nested structure while templates use flat structure - * + * * @param translations - Translations object (may contain 'general' wrapper) * @returns Flattened translations object */ function flattenTranslations(translations: any): any { if (!isObject(translations) || !translations.general) { - return translations; + return translations } // Extract 'general' properties to root level - const { general, ...rest } = translations; - return { ...general, ...rest }; + const { general, ...rest } = translations + return { ...general, ...rest } } /** * Get nested value from object using dot notation path - * + * * @param obj - Object to get value from * @param path - Dot notation path (e.g., "labels.inventoryLevelId") * @returns Value at path or undefined */ function getNestedValue(obj: any, path: string): any { - return path.split('.').reduce((current, key) => { - if (current && typeof current === 'object') { - return current[key]; + return path.split(".").reduce((current, key) => { + if (current && typeof current === "object") { + return current[key] } - return undefined; - }, obj); + return undefined + }, obj) } /** * Interpolate variables in text using {{variable}} syntax * Supports nested object paths (e.g., inventory_level.id) - * + * * @param text - Text with {{variable}} placeholders * @param data - Data object for interpolation * @returns Interpolated text */ -function interpolate(text: string, data: Record = {}): string { - if (!text || typeof text !== 'string') { - return text; +function interpolate( + text: string, + data: Record = {} +): string { + if (!text || typeof text !== "string") { + return text } - return text.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, key) => { - // Support nested keys (e.g., inventory_level.id) - const value = getNestedValue(data, key); - return value !== undefined && value !== null ? String(value) : match; - }); + return text.replace( + /\{\{(\w+(?:\.\w+)*)\}\}/g, + (match, key) => { + // Support nested keys (e.g., inventory_level.id) + const value = getNestedValue(data, key) + return value !== undefined && value !== null + ? String(value) + : match + } + ) } /** * Simple translation function - * + * * @param locale - Target locale (e.g., 'pl', 'en') * @param translations - Record of translations by locale * @param key - Translation key (supports dot notation: "labels.inventoryLevelId") @@ -85,24 +95,27 @@ export function t( data: Record = {} ): string { // Get translations for locale with fallback to 'pl' - const localeTranslations = translations[locale] || translations['pl'] || {}; - + const localeTranslations = + translations[locale] || translations["pl"] || {} + // Flatten translations structure - const flatTranslations = flattenTranslations(localeTranslations); - + const flatTranslations = flattenTranslations( + localeTranslations + ) + // Get translation value (supports nested keys) - const translation = getNestedValue(flatTranslations, key); - + const translation = getNestedValue(flatTranslations, key) + // Use key as fallback if translation not found - const text = translation || key; - + const text = translation || key + // Interpolate variables - return interpolate(text, data); + return interpolate(text, data) } /** * Create a translator function for a specific locale and translations - * + * * @param locale - Target locale * @param translations - Record of translations by locale * @returns Translator function that takes (key, data?) and returns translated string @@ -112,14 +125,14 @@ export function createTranslator( translations: Record ): (key: string, data?: Record) => string { return (key: string, data: Record = {}) => { - return t(locale, translations, key, data); - }; + return t(locale, translations, key, data) + } } /** * Merge custom translations with base translations * Custom translations override base translations - * + * * @param baseTranslations - Base translations object * @param customTranslations - Custom translations to merge (optional) * @returns Merged translations object @@ -128,13 +141,20 @@ export function mergeTranslations( baseTranslations: Record, customTranslations?: Record ): Record { - if (!customTranslations || !isObject(customTranslations)) { - return baseTranslations; + if ( + !customTranslations || + !isObject(customTranslations) + ) { + return baseTranslations } - const merged: Record = { ...baseTranslations }; + const merged: Record = { + ...baseTranslations, + } - for (const [lang, custom] of Object.entries(customTranslations)) { + for (const [lang, custom] of Object.entries( + customTranslations + )) { if (isObject(custom)) { merged[lang] = { ...baseTranslations[lang], @@ -142,10 +162,9 @@ export function mergeTranslations( ...(baseTranslations[lang]?.general || {}), ...(custom.general || custom), }, - }; + } } } - return merged; + return merged } - diff --git a/src/utils/i18n/index.ts b/src/utils/i18n/index.ts index 886ef8b..ec2c304 100644 --- a/src/utils/i18n/index.ts +++ b/src/utils/i18n/index.ts @@ -1 +1 @@ -export * from "./i18n" \ No newline at end of file +export * from "./i18n" diff --git a/src/utils/index.ts b/src/utils/index.ts index 7bdb8f5..2dc7b1c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./plugins" export * from "./validate-rules" export * from "./types" export * from "./i18n" +export * from "./attribute-helpers" diff --git a/src/utils/types/index.ts b/src/utils/types/index.ts index 4b483d2..2bce076 100644 --- a/src/utils/types/index.ts +++ b/src/utils/types/index.ts @@ -1,2 +1,2 @@ export * from "../../modules/mpn-automation/types" -export * from "../../templates/slack/types" \ No newline at end of file +export * from "../../templates/slack/types" diff --git a/src/utils/validate-rules.ts b/src/utils/validate-rules.ts index baef3dc..787c623 100644 --- a/src/utils/validate-rules.ts +++ b/src/utils/validate-rules.ts @@ -57,6 +57,34 @@ export function validateRuleValueCondition( (val) => !ruleValueSet.has(`${val}`) ) } + case "nin": { + const ruleValueSet = new Set(ruleValues) + return valuesToCheck.every( + (val) => !ruleValueSet.has(`${val}`) + ) + } + case "contains": { + // Check if array contains any of the rule values + const ruleValueSet = new Set(ruleValues) + return valuesToCheck.some((val) => + ruleValueSet.has(`${val}`) + ) + } + case "not_contains": { + // Check if array does NOT contain any of the rule values + const ruleValueSet = new Set(ruleValues) + return !valuesToCheck.some((val) => + ruleValueSet.has(`${val}`) + ) + } + case "empty": { + // Check if array is empty + return valuesToCheck.length === 0 + } + case "not_empty": { + // Check if array is not empty + return valuesToCheck.length > 0 + } case "gt": return valuesToCheck.every((val) => ruleValues.some((ruleVal) => @@ -104,8 +132,23 @@ export function validateRulesForContext( const validRuleValues: string[] = [] for (const value of rule.rule_values) { - if (value.value && isString(value.value)) { - validRuleValues.push(value.value) + if ( + value.value !== null && + value.value !== undefined + ) { + // Handle different value types + if (isString(value.value)) { + validRuleValues.push(value.value) + } else if (typeof value.value === "number") { + validRuleValues.push(`${value.value}`) + } else if (Array.isArray(value.value)) { + // If value is an array, convert all elements to strings + value.value.forEach((item) => { + if (item !== null && item !== undefined) { + validRuleValues.push(`${item}`) + } + }) + } } } diff --git a/src/workflows/inventory/steps/get-inventory-level-by-id.ts b/src/workflows/inventory/steps/get-inventory-level-by-id.ts index 8ac92af..588ef21 100644 --- a/src/workflows/inventory/steps/get-inventory-level-by-id.ts +++ b/src/workflows/inventory/steps/get-inventory-level-by-id.ts @@ -10,6 +10,8 @@ import { StepResponse, createStep, } from "@medusajs/framework/workflows-sdk" +import { INVENTORY_LEVEL_ATTRIBUTES } from "../../../modules/mpn-automation/types/modules/inventory" +import { getFieldsFromAttributes } from "../../../utils" export interface GetInventoryLevelByIdStepInput { inventory_level_id: string @@ -52,21 +54,17 @@ export const getInventoryLevelByIdStep = createStep( ) } + // Generate fields from INVENTORY_LEVEL_ATTRIBUTES to keep them in sync + const fields = getFieldsFromAttributes( + INVENTORY_LEVEL_ATTRIBUTES as Array<{ + value?: string + }>, + "inventory_level" + ) + const { data: inventoryLevels } = await query.graph({ entity: "inventory_level", - fields: [ - "id", - "inventory_item.*", - "stocked_quantity", - "reserved_quantity", - "incoming_quantity", - "available_quantity", - "location_id", - "stock_locations.id", - "stock_locations.name", - "stock_locations.address", - "stock_locations.metadata", - ], + fields, filters: { id: { $in: [input.inventory_level_id], diff --git a/src/workflows/mpn-automation/index.ts b/src/workflows/mpn-automation/index.ts index a436eb1..383fbd1 100644 --- a/src/workflows/mpn-automation/index.ts +++ b/src/workflows/mpn-automation/index.ts @@ -7,4 +7,4 @@ export * from "./edit-automation" export * from "./edit-automation-rules" export * from "./edit-automation-actions" export * from "./delete-automation" -export * from "./save-automation-state" \ No newline at end of file +export * from "./save-automation-state" diff --git a/src/workflows/mpn-automation/run-email-action.ts b/src/workflows/mpn-automation/run-email-action.ts index d3bf252..08184ee 100644 --- a/src/workflows/mpn-automation/run-email-action.ts +++ b/src/workflows/mpn-automation/run-email-action.ts @@ -67,7 +67,11 @@ export const runEmailActionWorkflow = createWorkflow( (input: WorkflowData) => { // Transform automation action format for sendEmailWorkflow const settings = transform( - { action: input.action, eventName: input.eventName, contextType: input.contextType }, + { + action: input.action, + eventName: input.eventName, + contextType: input.contextType, + }, (data) => { const actionConfig = data?.action?.config || {} const eventName = data?.eventName diff --git a/src/workflows/mpn-automation/run-slack-action.ts b/src/workflows/mpn-automation/run-slack-action.ts index df61212..d88bd0d 100644 --- a/src/workflows/mpn-automation/run-slack-action.ts +++ b/src/workflows/mpn-automation/run-slack-action.ts @@ -63,7 +63,11 @@ export const runSlackActionWorkflow = createWorkflow( (input: WorkflowData) => { // Transform automation action format for sendSlackWorkflow const settings = transform( - { action: input.action, eventName: input.eventName, contextType: input.contextType }, + { + action: input.action, + eventName: input.eventName, + contextType: input.contextType, + }, (data) => { const actionConfig = data?.action?.config || {} const eventName = data?.eventName diff --git a/src/workflows/mpn-automation/steps/edit-automation-actions.ts b/src/workflows/mpn-automation/steps/edit-automation-actions.ts index 350db27..4b3703c 100644 --- a/src/workflows/mpn-automation/steps/edit-automation-actions.ts +++ b/src/workflows/mpn-automation/steps/edit-automation-actions.ts @@ -13,14 +13,20 @@ type EditAutomationActionsStepInput = { const configWithUndefined = (config: any) => { return config - ? Object.entries(config).reduce((acc, [key, value]) => { - if (value === "") { - acc[key] = undefined - } else if (value !== null && value !== undefined) { - acc[key] = value - } - return acc - }, {} as Record) + ? Object.entries(config).reduce( + (acc, [key, value]) => { + if (value === "") { + acc[key] = undefined + } else if ( + value !== null && + value !== undefined + ) { + acc[key] = value + } + return acc + }, + {} as Record + ) : null } diff --git a/src/workflows/mpn-automation/steps/edit-automation-rules.ts b/src/workflows/mpn-automation/steps/edit-automation-rules.ts index e7328dc..01babc2 100644 --- a/src/workflows/mpn-automation/steps/edit-automation-rules.ts +++ b/src/workflows/mpn-automation/steps/edit-automation-rules.ts @@ -112,7 +112,7 @@ export const editAutomationRulesStep = createStep( await mpnAutomationService.updateMpnAutomationRuleValues( valuesToUpdate.map((value) => ({ id: value.id!, - value: value.value, + value: value.value as any, })) ) } @@ -121,7 +121,7 @@ export const editAutomationRulesStep = createStep( await mpnAutomationService.createMpnAutomationRuleValues( valuesToCreate.map((value) => ({ rule_id: rule.id!, - value: value.value, + value: value.value as any, })) ) } @@ -148,7 +148,7 @@ export const editAutomationRulesStep = createStep( await mpnAutomationService.createMpnAutomationRuleValues( rule.rule_values.map((value) => ({ rule_id: newRule[0].id, - value: value.value, + value: value.value as any, })) ) } diff --git a/src/workflows/mpn-automation/steps/retrieve-automation-triggers-by-event.ts b/src/workflows/mpn-automation/steps/retrieve-automation-triggers-by-event.ts index 03183d3..48df6b7 100644 --- a/src/workflows/mpn-automation/steps/retrieve-automation-triggers-by-event.ts +++ b/src/workflows/mpn-automation/steps/retrieve-automation-triggers-by-event.ts @@ -58,35 +58,23 @@ export const getAutomationTriggersByEventStep = createStep( id: trigger.id, name: trigger.name, description: trigger.description, - trigger_type: trigger.trigger_type as TriggerType, + trigger_type: trigger.trigger_type, event_name: trigger.event_name, interval_minutes: trigger.interval_minutes, active: trigger.active, - channels: trigger.channels as Record< - string, - boolean - > | null, - metadata: trigger.metadata as Record< - string, - any - > | null, + channels: trigger.channels, + metadata: trigger.metadata, rules: (trigger.rules || []).map((rule) => ({ id: rule.id, attribute: rule.attribute, operator: rule.operator, description: rule.description, - metadata: rule.metadata as Record< - string, - any - > | null, + metadata: rule.metadata, rule_values: (rule.rule_values || []).map( (value) => ({ id: value.id, - value: value.value, - metadata: value.metadata as Record< - string, - any - > | null, + value: value.value as any, + metadata: value.metadata, }) ), })), @@ -95,7 +83,7 @@ export const getAutomationTriggersByEventStep = createStep( action_type: action.action_type, config: action.config, })), - })) + })) as AutomationTrigger[] return new StepResponse(triggersData, triggersData) } diff --git a/src/workflows/mpn-automation/steps/run-automation-actions.ts b/src/workflows/mpn-automation/steps/run-automation-actions.ts index dcce693..eb4ff1f 100644 --- a/src/workflows/mpn-automation/steps/run-automation-actions.ts +++ b/src/workflows/mpn-automation/steps/run-automation-actions.ts @@ -59,7 +59,8 @@ export const runAutomationActionsStep = createStep( ContainerRegistrationKeys.LOGGER ) - const { validatedTriggers, context, contextType } = input + const { validatedTriggers, context, contextType } = + input if ( !validatedTriggers || diff --git a/src/workflows/notifications/steps/send-email.ts b/src/workflows/notifications/steps/send-email.ts index 6cc5ae3..fca72a5 100644 --- a/src/workflows/notifications/steps/send-email.ts +++ b/src/workflows/notifications/steps/send-email.ts @@ -77,7 +77,12 @@ export const sendEmailStep = createStep( input: SendEmailStepInput, { container } ): Promise> => { - const { settings, templateData, contextType, eventName } = input + const { + settings, + templateData, + contextType, + eventName, + } = input // Validate required config if (!settings.templateName) { @@ -161,11 +166,13 @@ export const sendEmailStep = createStep( } // Use action handler for template rendering - const mpnAutomationService = container.resolve( - "mpnAutomation" - ) - const emailHandler = mpnAutomationService.getActionHandler("email") - + const mpnAutomationService = + container.resolve( + "mpnAutomation" + ) + const emailHandler = + mpnAutomationService.getActionHandler("email") + if (!emailHandler?.handler?.renderTemplate) { throw new MedusaError( MedusaError.Types.NOT_FOUND, @@ -173,12 +180,13 @@ export const sendEmailStep = createStep( ) } - const { html, text, subject } = await emailHandler.handler.renderTemplate({ - templateName: templateName, - context: templateData, - contextType: contextType, - options: renderOptions, - }) + const { html, text, subject } = + await emailHandler.handler.renderTemplate({ + templateName: templateName, + context: templateData, + contextType: contextType, + options: renderOptions, + }) // Send notification const notificationResult = diff --git a/src/workflows/notifications/steps/send-slack.ts b/src/workflows/notifications/steps/send-slack.ts index 2909ff4..cc42d5c 100644 --- a/src/workflows/notifications/steps/send-slack.ts +++ b/src/workflows/notifications/steps/send-slack.ts @@ -66,7 +66,8 @@ export const sendSlackStep = createStep( input: SendSlackStepInput, { container } ): Promise> => { - const { settings, context, contextType, eventName } = input + const { settings, context, contextType, eventName } = + input // Validate required config if (!settings.template) { @@ -92,11 +93,13 @@ export const sendSlackStep = createStep( const backendUrl = settings.backendUrl || "" // Use action handler for template rendering - const mpnAutomationService = container.resolve( - "mpnAutomation" - ) - const slackHandler = mpnAutomationService.getActionHandler("slack") - + const mpnAutomationService = + container.resolve( + "mpnAutomation" + ) + const slackHandler = + mpnAutomationService.getActionHandler("slack") + if (!slackHandler?.handler?.renderTemplate) { throw new MedusaError( MedusaError.Types.NOT_FOUND, @@ -104,15 +107,16 @@ export const sendSlackStep = createStep( ) } - const { text, blocks } = await slackHandler.handler.renderTemplate({ - templateName: template, - context: context, - contextType: contextType, - options: { - locale: locale, - backendUrl: backendUrl, - }, - }) + const { text, blocks } = + await slackHandler.handler.renderTemplate({ + templateName: template, + context: context, + contextType: contextType, + options: { + locale: locale, + backendUrl: backendUrl, + }, + }) // Send notification const notificationResult = diff --git a/src/workflows/product-variant/steps/get-product-variant-by-id.ts b/src/workflows/product-variant/steps/get-product-variant-by-id.ts index 676796e..63b0646 100644 --- a/src/workflows/product-variant/steps/get-product-variant-by-id.ts +++ b/src/workflows/product-variant/steps/get-product-variant-by-id.ts @@ -68,15 +68,7 @@ export const getProductVariantByIdStep = createStep( "mid_code", "hs_code", "material", - "metadata", - "product.id", - "product.title", - "product.description", - "product.handle", - "product.is_giftcard", - "product.status", - "product.images", - "product.metadata", + "product.*", ], filters: { id: { diff --git a/src/workflows/product/steps/get-product-by-id.ts b/src/workflows/product/steps/get-product-by-id.ts index a50f20f..228f38d 100644 --- a/src/workflows/product/steps/get-product-by-id.ts +++ b/src/workflows/product/steps/get-product-by-id.ts @@ -7,18 +7,15 @@ import { StepResponse, createStep, } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_ATTRIBUTES } from "../../../modules/mpn-automation/types/modules/product" +import { getFieldsFromAttributes } from "../../../utils" export interface GetProductByIdStepInput { product_id: string } export interface GetProductByIdStepOutput { - product: ProductTypes.ProductDTO & { - variants?: ProductTypes.ProductVariantDTO[] - images?: ProductTypes.ProductImageDTO[] - tags?: ProductTypes.ProductTagDTO[] - categories?: ProductTypes.ProductCategoryDTO[] - } + product: ProductTypes.ProductDTO } export const getProductByIdStepId = "get-product-by-id" @@ -48,53 +45,15 @@ export const getProductByIdStep = createStep( ) } + // Generate fields from PRODUCT_ATTRIBUTES to keep them in sync + const fields = getFieldsFromAttributes( + PRODUCT_ATTRIBUTES, + "product" + ) + const { data: products } = await query.graph({ entity: "product", - fields: [ - "id", - "title", - "subtitle", - "description", - "handle", - "is_giftcard", - "status", - "thumbnail", - "weight", - "length", - "height", - "width", - "origin_country", - "hs_code", - "mid_code", - "material", - "metadata", - "created_at", - "updated_at", - "variants.id", - "variants.title", - "variants.sku", - "variants.barcode", - "variants.ean", - "variants.upc", - "variants.inventory_quantity", - "variants.allow_backorder", - "variants.manage_inventory", - "variants.weight", - "variants.length", - "variants.height", - "variants.width", - "variants.metadata", - "images.id", - "images.url", - "images.metadata", - "tags.id", - "tags.value", - "categories.id", - "categories.name", - "categories.handle", - "categories.description", - "categories.metadata", - ], + fields, filters: { id: { $in: [input.product_id],