Mechanic is a development and ecommerce automation platform for Shopify. :)
This task watches for inventory updates. When a product's total inventory becomes 0 or less, the current time will be recorded for that product. Then, on an hourly basis, this task will unpublish any products with a recorded out-of-stock time of at least x days ago.
Runs Occurs whenever an inventory level is updated, Occurs every hour, and Occurs when a user manually triggers the task. Configuration includes number of days to wait before unpublishing, sales channel names, only include products matching this search query, and test mode.
This task watches for inventory updates. When a product's total inventory becomes 0 or less, the current time will be recorded for that product. Then, on an hourly basis, this task will unpublish any products with a recorded out-of-stock time of at least x days ago.
Notes:
Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Shopifolks, everybody.
That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.
(By the way, have you seen our documentation? Have you joined the Slack community?)
shopify/inventory_levels/update mechanic/scheduler/hourly mechanic/user/trigger
{% comment %} 1. Watch for updates to inventory levels. 2. Look up the associated product, when an update comes in. 3. If the product's total inventory is <=0, record the current time in a metafield. If the total inventory is not 0, delete that metafield, if it exists. 4. Scan all products, on a schedule, retrieving each product's out-of-stock time metafield. For products found with a total inventory of <=0, with a recorded time that's at least a configurable distance in days from the current time, unpublish the product. {% endcomment %} {% assign number_of_days_to_wait_before_unpublishing = options.number_of_days_to_wait_before_unpublishing__number_required %} {% assign sales_channel_names = options.sales_channel_names__required_array %} {% assign only_include_products_matching_this_search_query = options.only_include_products_matching_this_search_query %} {% assign test_mode = options.test_mode__boolean %} {% assign time_format = "%FT%T%z" %} {% if event.topic == "shopify/inventory_levels/update" %} {% capture query %} query { inventoryLevel( id: {{ inventory_level.admin_graphql_api_id | json }} ) { item { variant { product { id title totalInventory metafield( namespace: "mechanic" key: "out_of_stock_at" ) { value } } } } } } {% endcapture %} {% assign result = query | shopify %} {% if event.preview %} {% capture result_json %} { "data": { "inventoryLevel": { "item": { "variant": { "product": { "id": "gid://shopify/Product/1234567890", "title": "Short sleeve t-shirt", "totalInventory": 0 } } } } } } {% endcapture %} {% assign result = result_json | parse_json %} {% endif %} {% assign product = result.data.inventoryLevel.item.variant.product %} {% if product.totalInventory <= 0 and product.metafield == blank %} {% if test_mode %} {% log %} {%- capture message -%} Product {{ product.title | json }} ({{ product.id }}) is out of stock. Its out-of-stock time should be recorded. {%- endcapture -%} {{ message | json }} {% endlog %} {% else %} {% action "shopify" %} mutation { metafieldsSet( metafields: [ { ownerId: {{ product.id | json }} namespace: "mechanic" key: "out_of_stock_at" type: "date_time" value: {{ "now" | date: time_format | json }} } ] ) { metafields { id namespace key type value owner { ... on Product { id title } } } userErrors { code field message } } } {% endaction %} {% endif %} {% elsif product.totalInventory > 0 and product.metafield != blank %} {% if test_mode %} {% log %} {%- capture message -%} Product {{ product.title | json }} ({{ product.id }}) is back in stock. Its out-of-stock time should be cleared. {%- endcapture -%} {{ message | json }} {% endlog %} {% else %} {% action "shopify" %} mutation { metafieldsDelete( metafields: [ { ownerId: {{ product.id | json }} namespace: "mechanic" key: "out_of_stock_at" } ] ) { deletedMetafields { ownerId namespace key } userErrors { field message } } } {% endaction %} {% endif %} {% endif %} {% elsif event.topic contains "mechanic/scheduler/" or event.topic == "mechanic/user/trigger" %} {% assign now_s = "now" | date: "%s" | times: 1 %} {% assign minimum_out_of_stock_at_distance_s = number_of_days_to_wait_before_unpublishing | times: 24 | times: 60 | times: 60 %} {% comment %} -- get all of the sales channel names (i.e. publications aka app catalogs) in the shop {% endcomment %} {% capture query %} query { publications( first: 250 catalogType:APP ) { nodes { id catalog { ... on AppCatalog { apps(first: 1) { nodes { title } } } } } } } {% endcapture %} {% assign result = query | shopify %} {% if event.preview %} {% capture result_json %} { "data": { "publications": { "nodes": [ { "id": "gid://shopify/Publication/1234567890", "catalog": { "apps": { "nodes": [ { "title": {{ sales_channel_names.first | json }} } ] } } } ] } } } {% endcapture %} {% assign result = result_json | parse_json %} {% endif %} {% comment %} -- save the publication IDs for all of the configured sales channel names {% endcomment %} {% assign publication_ids = array %} {% assign available_sales_channel_names = array %} {% for publication in result.data.publications.nodes %} {% assign publication_name = publication.catalog.apps.nodes.first.title %} {% assign available_sales_channel_names = available_sales_channel_names | push: publication_name %} {% if sales_channel_names contains publication_name %} {% assign publication_ids = publication_ids | push: publication.id %} {% endif %} {% endfor %} {% if publication_ids == blank %} {% error message: "None of the sales channels configured in this task exist in the shop. Check the list of available channels and verify each configured channel exists.", configured_sales_channel_names: sales_channel_names, available_sales_channel_names: available_sales_channel_names %} {% break %} {% elsif publication_ids.size != sales_channel_names.size %} {% unless event.preview %} {% comment %} -- using action error here so the task will continue with any other configured and matched sales channels {% endcomment %} {% action "echo" __error: "One or more configured sales channel names do not match any of the publication names available in this shop.", configured_sales_channel_names: sales_channel_names, available_sales_channel_names: available_sales_channel_names %} {% endunless %} {% endif %} {% comment %} -- query for all active, out of stock products (up to 25K); optionally filter the products with configured query {% endcomment %} {% assign search_query = "status:active AND inventory_total:<=0" %} {% if only_include_products_matching_this_search_query != blank %} {%- capture search_query -%} {{ search_query }} AND ({{ only_include_products_matching_this_search_query }}) {%- endcapture -%} {% log search_query: search_query %} {% endif %} {% assign cursor = nil %} {% assign products = array %} {% assign metafields_to_set = array %} {% for n in (1..100) %} {% capture query %} query { products( first: 250 query: {{ search_query | json }} after: {{ cursor | json }} ) { pageInfo { hasNextPage endCursor } nodes { id title totalInventory {% for publication_id in publication_ids %} publishedOnPublication{{ forloop.index }}: publishedOnPublication(publicationId: {{ publication_id | json }}) {% endfor %} metafield( namespace: "mechanic" key: "out_of_stock_at" ) { id value } } } } {% endcapture %} {% assign result = query | shopify %} {% if event.preview %} {% capture result_json %} { "data": { "products": { "nodes": [ { "id": "gid://shopify/Product/1234567890", "title": "Short sleeve t-shirt", "totalInventory": 0, "publishedOnPublication1": true, "metafield": { "value": {{ now_s | minus: minimum_out_of_stock_at_distance_s | minus: 1 | append: "" | json }} } } ] } } } {% endcapture %} {% assign result = result_json | parse_json %} {% endif %} {% assign products = products | concat: result.data.products.nodes %} {% if result.data.products.pageInfo.hasNextPage %} {% assign cursor = result.data.products.pageInfo.endCursor %} {% else %} {% break %} {% endif %} {% endfor %} {% for product in products %} {% if product.metafield == blank %} {% log %} "Product {{ product.id }} is out of stock, but its out-of-stock time was not recorded. This product will not be unpublished now, but its out-of-stock time will be set to the current time." {% endlog %} {% assign metafield_to_set = hash %} {% assign metafield_to_set["ownerId"] = product.id %} {% assign metafield_to_set["namespace"] = "mechanic" %} {% assign metafield_to_set["key"] = "out_of_stock_at" %} {% assign metafield_to_set["type"] = "date_time" %} {% assign metafield_to_set["value"] = "now" | date: time_format %} {% assign metafields_to_set = metafields_to_set | push: metafield_to_set %} {% continue %} {% endif %} {% assign out_of_stock_at_s = product.metafield.value | date: "%s" | times: 1 %} {% assign out_of_stock_at_distance_s = now_s | minus: out_of_stock_at_s %} {% assign unpublishings = array %} {% for publication_id in publication_ids %} {% assign key = "publishedOnPublication" | append: forloop.index %} {% if product[key] %} {% assign unpublishings[unpublishings.size] = array | push: product.id, publication_id %} {% endif %} {% endfor %} {% if unpublishings == blank %} {% log %} {%- capture message -%} Product {{ product.title | json }} ({{ product.id }}) has been out of stock for {{ out_of_stock_at_distance_s | divided_by: 86400 | round: 2 }} day(s), but is not published - nothing to do. {%- endcapture -%} {{ message | json }} {% endlog %} {% continue %} {% endif %} {% if out_of_stock_at_distance_s < minimum_out_of_stock_at_distance_s %} {% log %} {%- capture message -%} Product {{ product.title | json }} ({{ product.id }}) is out of stock, and is published, but has only been out of stock for {{ out_of_stock_at_distance_s | divided_by: 86400 | round: 2 }} day(s). {%- endcapture -%} {{ message | json }} {% endlog %} {% continue %} {% endif %} {% if test_mode %} {% log %} {%- capture message -%} Product {{ product.title | json }} ({{ product.id }}) is out of stock, and has been out of stock for {{ out_of_stock_at_distance_s | divided_by: 86400 | round: 2 }} day(s). It should be unpublished. {%- endcapture -%} {{ message | json }} {% endlog %} {% else %} {% action "shopify" %} mutation { {% for unpublishing in unpublishings %} publishableUnpublish{{ forloop.index }}: publishableUnpublish( id: {{ unpublishing[0] | json }} input: { publicationId: {{ unpublishing[1] | json }} } ) { userErrors { field message } } {% endfor %} } {% endaction %} {% endif %} {% endfor %} {% if metafields_to_set != blank %} {% if test_mode %} {% log %} "Found {{ metafields_to_set.size }} out of stock products which do not yet have their out of stock time recorded. This task has test mode enabled, so these metafields will not be set on this task run." {% endlog %} {% break %} {% endif %} {% assign groups_of_metafields_to_set = metafields_to_set | in_groups_of: 25, fill_with: false %} {% for group_of_metafields_to_set in groups_of_metafields_to_set %} {% action "shopify" %} mutation { metafieldsSet( metafields: {{ group_of_metafields_to_set | graphql_arguments }} ) { metafields { id namespace key type value owner { ... on Product { id title } } } userErrors { code field message } } } {% endaction %} {% endfor %} {% endif %} {% endif %}
["Online Store"]
true