diff --git a/.github/workflows/projects.json b/.github/workflows/projects.json index d5fe1a49..4c3c0e31 100644 --- a/.github/workflows/projects.json +++ b/.github/workflows/projects.json @@ -34,5 +34,8 @@ }, { "path": "integrator-default-profile/samples/github-issue-to-google-chat" + }, + { + "path": "integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms" } ] diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/component.yaml b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/component.yaml new file mode 100644 index 00000000..b421546a --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/component.yaml @@ -0,0 +1,24 @@ +# +required The configuration file schema version +schemaVersion: 1.2 + +# +optional Incoming connection details for the component +endpoints: + # +required Unique name for the endpoint. + # This name will be used when generating the managed API + - name: webhook + # +optional Display name for the endpoint. + displayName: WebHook + # +required Service section has the user service endpoint details + service: + # +optional Base path of the API that gets exposed via the endpoint. + # This is mandatory if the endpoint type is set to REST or GraphQL. + basePath: / + # +required Numeric port value that gets exposed via the endpoint + port: 8090 + # +required Type of traffic that the endpoint is accepting. + # Allowed values: REST, GraphQL, GRPC, TCP, UDP. + type: REST + # +optional Network level visibilities of the endpoint. + # Accepted values: Project|Organization|Public(Default). + networkVisibilities: + - Public diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/config-schema.json b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/config-schema.json new file mode 100644 index 00000000..cd388d28 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/config-schema.json @@ -0,0 +1,238 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "ballerina": { + "type": "object", + "properties": { + "log": { + "type": "object", + "properties": { + "destinations": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "stderr", + "stdout" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ], + "name": "ballerina/log:2.17.0:StandardDestination" + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "file" + ] + }, + "path": { + "type": "string" + }, + "mode": { + "enum": [ + "APPEND", + "TRUNCATE" + ] + }, + "rotation": { + "type": "object", + "properties": { + "policy": { + "enum": [ + "BOTH", + "TIME_BASED", + "SIZE_BASED" + ] + }, + "maxFileSize": { + "type": "integer" + }, + "maxAge": { + "type": "integer" + }, + "maxBackupFiles": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "policy", + "maxFileSize", + "maxAge", + "maxBackupFiles" + ], + "name": "ballerina/log:2.17.0:RotationConfig" + } + }, + "additionalProperties": false, + "required": [ + "type", + "path", + "mode" + ], + "name": "ballerina/log:2.17.0:FileOutputDestination" + } + ] + }, + "description": "" + }, + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "level": { + "enum": [ + "WARN", + "INFO", + "ERROR", + "DEBUG" + ] + } + }, + "additionalProperties": false, + "required": [ + "name", + "level" + ], + "name": "ballerina/log:2.17.0:Module" + }, + "description": "" + }, + "keyValues": { + "type": "object", + "properties": { + "msg": {}, + "message": {}, + "time": {}, + "level": {}, + "error": {}, + "stackTrace": {}, + "module": {}, + "icp\\.runtimeId": {} + }, + "additionalProperties": false, + "name": "ballerina/log:2.17.0:AnydataKeyValues", + "description": "" + }, + "format": { + "enum": [ + "logfmt", + "json" + ], + "description": "" + }, + "level": { + "enum": [ + "WARN", + "INFO", + "ERROR", + "DEBUG" + ], + "description": "" + }, + "enableSensitiveDataMasking": { + "type": "boolean", + "description": "" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "wso2": { + "type": "object", + "properties": { + "shopify_inventory_alert_via_twilio_sms": { + "type": "object", + "properties": { + "shopifyConfig": { + "type": "object", + "properties": { + "storeUrl": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "apiSecretKey": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "storeUrl", + "accessToken", + "apiSecretKey" + ], + "description": "" + }, + "twilioConfig": { + "type": "object", + "properties": { + "accountSid": { + "type": "string" + }, + "authToken": { + "type": "string" + }, + "fromNumber": { + "type": "string" + }, + "recipientNumbers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "accountSid", + "authToken", + "fromNumber", + "recipientNumbers" + ], + "description": "" + }, + "inventoryThreshold": { + "type": "integer", + "description": "" + }, + "smsTemplate": { + "type": "string", + "description": "" + }, + "cooldownPeriodHours": { + "type": "number", + "description": "" + } + }, + "additionalProperties": false, + "required": [ + "shopifyConfig", + "twilioConfig" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "requiredLevel": 3 +} diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/diagram.md b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/diagram.md new file mode 100644 index 00000000..f912799b --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/diagram.md @@ -0,0 +1,12 @@ +A(["Begin"]):::startNode +B["Receive Shopify Order Webhook"]:::processNode +C{"Inventory Below
Threshold?"}:::decisionNode +D{"Cooldown
Expired?"}:::decisionNode +E["Send SMS Alert via Twilio"]:::processNode +F(["Complete"]):::endNode + +A --> B --> C +C -- Yes --> D +C -- No --> F +D -- Yes --> E --> F +D -- No --> F diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/instructions.md b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/instructions.md new file mode 100644 index 00000000..d65d93b8 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.choreo/instructions.md @@ -0,0 +1,62 @@ +## What It Does + +- Receives a real-time webhook from Shopify whenever a new order is created +- Checks the current inventory level of each product variant in the order against a configurable threshold +- Sends an SMS alert via Twilio to one or more recipient numbers when a product's inventory drops below the configured threshold +- Suppresses repeat alerts for the same SKU until a configurable cooldown period expires +- Supports a customisable SMS message template with product and inventory placeholders + +
+ +Shopify Setup Guide + +1. Log in to your Shopify Admin. Your store URL follows the pattern `https://.myshopify.com`. Set this as `storeUrl`. +2. Go to **Settings** > **Apps and sales channels** > **Develop apps** and create an app with `read_products` and `read_inventory` API scopes. Install the app and copy the **Admin API access token** — this is your `accessToken`. +3. Log in to your Shopify account and navigate to **Settings** > **Notifications**. +4. Click on the **Webhooks** section. +5. Copy the key shown under _"Your webhooks will be signed with ..."_. This should be the `apiSecretKey` configuration. + +The following should be done after deploying the integration, and the endpoint URL is available. + +1. Log in to your Shopify account and navigate to **Settings** > **Notifications**. +2. Click on the **Webhooks** section and click on **Create webhook**. +3. In the **Create webhook** form, select the following options: + - **Event**: Select **Order creation** from the dropdown menu. + - **Format**: Choose **JSON** as the format for the webhook payload. + - **URL**: Enter the deployed integration's endpoint URL. +4. Go back to the Integration Overview page, and click on **Configure Security**. Disable **OAuth2** and click on **Apply**. + +
+ +
+ +Twilio Setup Guide + +1. Create a Twilio account and obtain a phone number +2. Obtain the Account SID and Auth Token from the Twilio Console +3. (Optional) Enable Geographic Permissions if sending to international numbers + +Refer to the [Twilio documentation](https://www.twilio.com/docs/messaging/quickstart) for steps for obtaining credentials. + +
+ +
+ +Additional Configurations + +1. `inventoryThreshold` + - An alert is sent when an ordered product's inventory falls below this number (default: `10`) +2. `recipientNumbers` + - Array of recipient phone numbers in E.164 format (e.g., `["+94711234567"]`) +3. `cooldownPeriodHours` + - Minimum hours between repeat alerts for the same SKU (default: `24`) +4. `smsTemplate` + - Customisable SMS message template. Available placeholders: + - `{{product.id}}` — Shopify product ID + - `{{product.name}}` — Product name + - `{{product.inventory}}` — Current inventory count + - `{{product.sku}}` — Product variant SKU + - `{{threshold}}` — Configured inventory threshold + - Default: `INVENTORY ALERT: {{product.name}} (ID: {{product.id}}) is low on stock. Current inventory: {{product.inventory}}. SKU: {{product.sku}}. Threshold: {{threshold}}` + +
diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.gitignore b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.gitignore new file mode 100644 index 00000000..8e484521 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/.gitignore @@ -0,0 +1,13 @@ +# Ballerina generates this directory during the compilation of a package. +# It contains compiler-generated artifacts and the final executable if this is an application package. +target/ + +# Ballerina maintains the compiler-generated source code here. +# Remove this if you want to commit generated sources. +generated/ + +# Contains configuration values used during development time. +# See https://ballerina.io/learn/provide-values-to-configurable-variables/ for more details. +Config.toml +config.toml +Dependencies.toml diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/Ballerina.toml b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/Ballerina.toml new file mode 100644 index 00000000..dfb42ce7 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/Ballerina.toml @@ -0,0 +1,9 @@ +[package] +org = "wso2" +name = "shopify_inventory_alert_via_twilio_sms" +version = "0.1.0" +title = "shopify-inventory-alert-via-twilio-sms" +distribution = "2201.13.1" + +[build-options] +sticky = false diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/README.md b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/README.md new file mode 100644 index 00000000..f77991db --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/README.md @@ -0,0 +1,89 @@ +# Shopify Inventory Alert via Twilio SMS + +## Description + +This integration listens for new orders on your Shopify store and automatically sends SMS notifications via Twilio when an ordered product's inventory falls below a defined threshold. + +### What It Does + +- Receives a real-time webhook from Shopify whenever a new order is created +- Checks the current inventory level of each product variant in the order against a configurable threshold +- Sends SMS alerts to one or more recipient phone numbers when stock is low +- Enforces a per-SKU cooldown period to prevent duplicate alerts within a configured time window +- Supports a fully customizable SMS message template with product-specific placeholders + +## Prerequisites + +Before running this integration, you need: + +### Shopify Setup + +- A Shopify store with Admin API access +- A Shopify Admin API access token with the following scopes: `read_products`, `read_inventory` +- Your store's permanent `.myshopify.com` domain +- To create a new app and obtain an access token, follow the [Shopify Admin API getting started guide](https://shopify.dev/docs/api/admin-rest) +- A webhook configured in **Shopify Admin → Settings → Notifications → Webhooks**: + - Copy the signing secret shown under _"Your webhooks will be signed with"_ — this is your `apiSecretKey` + - Click **Create webhook**, set **Event** to `Order creation`, **Format** to `JSON`, and set the **URL** to the public endpoint of this deployed integration + +### Twilio Setup + +- A Twilio account with an active SMS-capable phone number +- The recipient country must be enabled under **Twilio Console → Messaging → Settings → Geo Permissions** +- For trial accounts, recipient numbers must be verified under **Twilio Console → Phone Numbers → Verified Caller IDs** + +## Configuration + +The following configurations are required to connect to Shopify and Twilio. + +### Shopify Credentials + +- `storeUrl` - Your store URL (e.g., `https://your-store.myshopify.com`) +- `accessToken` - Admin API access token (`shpat_...`) +- `apiSecretKey` - Webhook signing secret from **Shopify Admin → Settings → Notifications → Webhooks** + +### Twilio Credentials + +- `accountSid` - Your Twilio Account SID (`AC...`) +- `authToken` - Your Twilio Auth Token +- `fromNumber` - Your Twilio phone number in E.164 format (e.g., `+12025551234`) +- `recipientNumbers` - One or more recipient phone numbers in E.164 format + +### Inventory Monitoring + +- `inventoryThreshold` - Minimum stock level that triggers an alert (default: `10`) +- `cooldownPeriodHours` - Minimum hours before re-alerting on the same SKU (default: `24`) + +### Notification Settings + +- `smsTemplate` - Customizable SMS message using the placeholders below + +#### SMS Template Placeholders + +- `{{product.id}}` - Shopify product ID +- `{{product.name}}` - Display name of the product +- `{{product.inventory}}` - Current stock quantity +- `{{product.sku}}` - Product variant SKU +- `{{threshold}}` - Configured inventory threshold + +**Default template:** +```text +INVENTORY ALERT: {{product.name}} (ID: {{product.id}}) is low on stock. Current inventory: {{product.inventory}}. SKU: {{product.sku}}. Threshold: {{threshold}} +``` + +**Example output:** +```text +INVENTORY ALERT: Blue Denim Jacket (ID: 8194105999407) is low on stock. Current inventory: 3. SKU: BDJ-001. Threshold: 10 +``` + +## Deploying on **Devant** + +1. Sign in to your Devant account. +2. Create a new Integration and follow the instructions in [Devant Documentation](https://wso2.com/devant/docs/references/import-a-repository/) to import this repository. +3. Select the **Technology** as `WSO2 Integrator: BI`. +4. Choose the **Integration Type** as `Trigger` and click **Create**. +5. Once the build is successful, click **Configure to Continue** and set up the required environment variables for Shopify and Twilio credentials. +6. Click **Deploy** to start the integration. +7. Copy the public endpoint URL of the deployed integration. +8. In **Shopify Admin → Settings → Notifications → Webhooks**, click **Create webhook**, set **Event** to `Order creation`, **Format** to `JSON`, and paste the endpoint URL. +9. Once tested, promote the integration to production. Make sure to set the relevant environment variables in the production environment as well. diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/agents.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/agents.bal new file mode 100644 index 00000000..e69de29b diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/automation.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/automation.bal new file mode 100644 index 00000000..e69de29b diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/config.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/config.bal new file mode 100644 index 00000000..94ab72c6 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/config.bal @@ -0,0 +1,21 @@ +configurable record { + string storeUrl; + string accessToken; + string apiSecretKey; +} shopifyConfig = ?; + +configurable record { + string accountSid; + string authToken; + string fromNumber; + string[] recipientNumbers; +} twilioConfig = ?; + +// Inventory threshold configuration +configurable int inventoryThreshold = 10; + +// SMS template with placeholders: {{product.name}}, {{product.id}}, {{product.inventory}}, {{product.sku}}, {{threshold}} +configurable string smsTemplate = "INVENTORY ALERT: {{product.name}} (ID: {{product.id}}) is low on stock. Current inventory: {{product.inventory}}. SKU: {{product.sku}}. Threshold: {{threshold}}"; + +// Cooldown period in hours (don't re-alert same SKU within X hours) +configurable decimal cooldownPeriodHours = 24.0; diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/connections.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/connections.bal new file mode 100644 index 00000000..c87f6c7b --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/connections.bal @@ -0,0 +1,22 @@ +import ballerinax/trigger.shopify; +import ballerinax/shopify.admin; +import ballerinax/twilio; + +shopify:ListenerConfig listenerConfig = { + apiSecretKey: shopifyConfig.apiSecretKey +}; + +listener shopify:Listener shopifyListener = new (listenerConfig, 8090); + +// Twilio client +final twilio:Client twilioClient = check new ({ + auth: { + accountSid: twilioConfig.accountSid, + authToken: twilioConfig.authToken + } +}); + +// Shopify admin client (used to fetch current inventory levels for ordered products) +final admin:Client adminClient = check new ({ + xShopifyAccessToken: shopifyConfig.accessToken +}, shopifyConfig.storeUrl); diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/data_mappings.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/data_mappings.bal new file mode 100644 index 00000000..e69de29b diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/functions.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/functions.bal new file mode 100644 index 00000000..61857e6c --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/functions.bal @@ -0,0 +1,209 @@ +import ballerina/log; +import ballerina/time; +import ballerinax/trigger.shopify; +import ballerinax/shopify.admin; +import ballerinax/twilio; + +// Fetch current inventory for all variants of a product from Shopify Admin API. +function getProductInventory(int productId) returns map|error { + admin:ProductList response = check adminClient->getProducts(ids = productId.toString()); + anydata productsData = response["products"]; + if productsData is () { + return {}; + } + admin:Product[] adminProducts = check productsData.cloneWithType(); + if adminProducts.length() == 0 { + return {}; + } + + map variantInventory = {}; + admin:Product product = adminProducts[0]; + string productTitle = product?.title ?: ""; + admin:ProductVariant[]? variants = product?.variants; + if variants is admin:ProductVariant[] { + foreach admin:ProductVariant variant in variants { + int variantId = variant?.id ?: 0; + if variantId == 0 { + continue; + } + int? inventoryQty = variant?.inventory_quantity; + if inventoryQty is int { + variantInventory[variantId.toString()] = { + productId: productId, + variantId: variantId, + productName: productTitle, + variantTitle: variant?.title ?: "", + sku: variant?.sku ?: "", + inventory: inventoryQty + }; + } + } + } + return variantInventory; +} + +// Process line items from a new Shopify order: for each ordered product variant, +// fetch current inventory and send an SMS alert if below the threshold. +function processOrderedLineItems(shopify:LineItem[] lineItems) returns error? { + map> inventoryCache = {}; + + foreach shopify:LineItem lineItem in lineItems { + int productId = lineItem?.product_id ?: 0; + int variantId = lineItem?.variant_id ?: 0; + if productId == 0 || variantId == 0 { + continue; + } + + // Fetch current inventory from Shopify Admin API, using cache to avoid duplicate calls + string productIdKey = productId.toString(); + if !inventoryCache.hasKey(productIdKey) { + map|error inventoryResult = getProductInventory(productId); + if inventoryResult is error { + log:printError("Failed to fetch inventory for product", + productId = productId, 'error = inventoryResult); + continue; + } + inventoryCache[productIdKey] = inventoryResult; + } + + ProductInventoryInfo? productInfo = inventoryCache[productIdKey][variantId.toString()]; + if productInfo is () { + continue; + } + + if productInfo.inventory >= inventoryThreshold { + log:printDebug("Inventory above threshold, skipping alert", + product = productInfo.productName, + sku = productInfo.sku, + inventory = productInfo.inventory, + threshold = inventoryThreshold); + continue; + } + + string sku = productInfo.sku; + string variantKey = "variant:" + productInfo.variantId.toString(); + + // Skip if variant-level cooldown has not expired + if !isCooldownExpired(variantKey) { + log:printInfo("Skipping alert: cooldown period active for variant", + variantId = productInfo.variantId, + sku = sku, + cooldownPeriodHours = cooldownPeriodHours); + continue; + } + + log:printWarn("Low inventory detected for ordered product", + product = productInfo.productName, + sku = sku, + inventory = productInfo.inventory, + threshold = inventoryThreshold); + + RecipientDeliveryResult[] deliveryResults = sendInventoryAlert(productInfo); + int successCount = 0; + decimal currentTime = time:monotonicNow(); + + foreach RecipientDeliveryResult result in deliveryResults { + if result.success { + successCount += 1; + lock { + cooldownTracker["recipient:" + result.recipient + "|variant:" + productInfo.variantId.toString()] = { + lastAlertTime: currentTime, + inventory: productInfo.inventory + }; + } + } else { + string detail = result.errorDetail ?: "unknown error"; + log:printError("Failed to send alert to recipient", + recipient = maskPhone(result.recipient), + product = productInfo.productName, + detail = detail); + } + } + + if successCount > 0 { + log:printInfo("Inventory alert sent", + successCount = successCount, + totalRecipients = deliveryResults.length()); + } + + // Set variant-level cooldown when at least one recipient was successfully notified. + // Recipients in individual cooldown were already notified recently. + if successCount > 0 { + lock { + cooldownTracker[variantKey] = { + lastAlertTime: currentTime, + inventory: productInfo.inventory + }; + } + } + } +} + +// Function to check if cooldown period has passed +function isCooldownExpired(string key) returns boolean { + lock { + AlertCooldown? cooldownInfo = cooldownTracker[key]; + if cooldownInfo is () { + return true; + } + decimal currentTime = time:monotonicNow(); + decimal timeDifference = currentTime - cooldownInfo.lastAlertTime; + decimal cooldownSeconds = cooldownPeriodHours * 3600.0; + return timeDifference >= cooldownSeconds; + } +} + +// Function to format SMS message using template +function formatSmsMessage(ProductInventoryInfo productInfo) returns string { + string:RegExp productIdPattern = re `\{\{product\.id\}\}`; + string:RegExp productNamePattern = re `\{\{product\.name\}\}`; + string:RegExp inventoryPattern = re `\{\{product\.inventory\}\}`; + string:RegExp skuPattern = re `\{\{product\.sku\}\}`; + string:RegExp thresholdPattern = re `\{\{threshold\}\}`; + + string message = smsTemplate; + message = productIdPattern.replaceAll(message, productInfo.productId.toString()); + message = productNamePattern.replaceAll(message, productInfo.productName); + message = inventoryPattern.replaceAll(message, productInfo.inventory.toString()); + message = skuPattern.replaceAll(message, productInfo.sku); + message = thresholdPattern.replaceAll(message, inventoryThreshold.toString()); + return message; +} + +// Returns a masked representation of a phone number for safe logging. +function maskPhone(string phone) returns string { + int len = phone.length(); + if len <= 4 { + return "****"; + } + string maskedPrefix = re `\d`.replaceAll(phone.substring(0, len - 4), "*"); + return maskedPrefix + phone.substring(len - 4); +} + +// Send SMS via Twilio to all configured recipients. +// Returns per-recipient delivery results so callers can track cooldown state individually. +function sendInventoryAlert(ProductInventoryInfo productInfo) returns RecipientDeliveryResult[] { + string messageBody = formatSmsMessage(productInfo); + RecipientDeliveryResult[] results = []; + + foreach string recipientNumber in twilioConfig.recipientNumbers { + // Skip recipients whose individual cooldown has not expired + if !isCooldownExpired("recipient:" + recipientNumber + "|variant:" + productInfo.variantId.toString()) { + continue; + } + + twilio:CreateMessageRequest messageRequest = { + To: recipientNumber, + From: twilioConfig.fromNumber, + Body: messageBody + }; + + twilio:Message|error sendResult = twilioClient->createMessage(messageRequest); + if sendResult is error { + results.push({recipient: recipientNumber, success: false, errorDetail: sendResult.message()}); + } else { + results.push({recipient: recipientNumber, success: true, errorDetail: ()}); + } + } + return results; +} diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/main.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/main.bal new file mode 100644 index 00000000..3411a983 --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/main.bal @@ -0,0 +1,44 @@ +import ballerinax/trigger.shopify; +import ballerina/log; + +// Track per-variant and per-recipient cooldowns to avoid duplicate alerts within the cooldown window. +// NOTE: This map is process-local (in-memory only). State is lost on restart. +isolated map cooldownTracker = {}; + +function init() { + log:printInfo("Shopify inventory alert service started. Listening for Shopify order events", + port = 8090, + inventoryThreshold = inventoryThreshold, + cooldownPeriodHours = cooldownPeriodHours); +} + +service shopify:OrdersService on shopifyListener { + + remote function onOrdersCreate(shopify:OrderEvent event) returns error? { + int orderId = event?.id ?: 0; + log:printInfo("Trigger fired: new Shopify order received", orderId = orderId); + + shopify:LineItem[]? lineItems = event?.line_items; + if lineItems is () || lineItems.length() == 0 { + log:printInfo("No line items found in order", orderId = orderId); + return; + } + + check processOrderedLineItems(lineItems); + } + + remote function onOrdersUpdated(shopify:OrderEvent event) returns error? { + } + + remote function onOrdersCancelled(shopify:OrderEvent event) returns error? { + } + + remote function onOrdersFulfilled(shopify:OrderEvent event) returns error? { + } + + remote function onOrdersPaid(shopify:OrderEvent event) returns error? { + } + + remote function onOrdersPartiallyFulfilled(shopify:OrderEvent event) returns error? { + } +} diff --git a/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/types.bal b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/types.bal new file mode 100644 index 00000000..a8f668de --- /dev/null +++ b/integrator-default-profile/samples/shopify-inventory-alert-via-twilio-sms/types.bal @@ -0,0 +1,22 @@ +// Cooldown tracking record +type AlertCooldown record { + decimal lastAlertTime; + int inventory; +}; + +// Product inventory information passed through the alert chain +type ProductInventoryInfo record {| + int productId; + int variantId; + string productName; + string variantTitle; + string sku; + int inventory; +|}; + +// Per-recipient SMS delivery result +type RecipientDeliveryResult record {| + string recipient; + boolean success; + string? errorDetail; +|};