Auto-tag products by "back in stock" age, with Mechanic.

Mechanic is a development and ecommerce automation platform for Shopify. :)

Auto-tag products by "back in stock" age

This task monitors changes to inventory level, records the time at which a product's total inventory passes your configured threshold, and then maintains a set of tags indicating how recently the product returned to "back in stock" status.

Runs Occurs whenever an inventory level is updated, Occurs every day at midnight (in local time), Occurs when a user manually triggers the task, and Occurs when a bulk operation is completed. Configuration includes back in stock inventory level, product tags and maximum age in days, include new products, run hourly, run daily, and force usage of creation date on manual runs.

15-day free trial – unlimited tasks

Documentation

This task monitors changes to inventory level, records the time at which a product's total inventory passes your configured threshold, and then maintains a set of tags indicating how recently the product returned to "back in stock" status.

This task operates in two phases.

Phase one runs in response to inventory level changes. When a change brings a product's total inventory to at or above your configured threshold, the task will record the "back in stock" time for that product.

Phase two occurs when you run this task manually, or when it is run daily or hourly. In this phase, the task scans every product in your shop and updates tags in bulk according to the task's configuration and according to the recorded "back in stock" time for each product.

Configure "Product tags and maximum age in days" with product tags on the left, and the maximum product age to consider on the right. For example, a product tag of "new-5" with a maximum age in days of "5" will be maintained on all products that have a "back in stock: time within the last 5 days. Once a product ages beyond that threshold, this task will remove that tag during the next "phase two" run.

Note: when this task first encounters a product on either an inventory level change or a bulk scan, the task will record the current time as the "first seen time". No tags will be added to a product until a "back in stock" time is recorded that is newer than the "first seen time". Optionally, choosing to "Include new products" means that their "first seen time" will instead be set as their product creation date, and they will be tagged as soon as they have inventory that meets the configured threshold.

Developer details

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?)

Open source
View on GitHub to contribute to this task
Subscriptions
{% if options.include_new_products__boolean %}
  shopify/products/create
{% endif %}
shopify/inventory_levels/update
{% if options.run_hourly__boolean %}
  mechanic/scheduler/hourly
{% elsif options.run_daily__boolean %}
  mechanic/scheduler/daily
{% endif %}
mechanic/user/trigger
mechanic/shopify/bulk_operation
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
back in stock inventory level (number, required), product tags and maximum age in days (keyval, number, required), include new products (boolean), run hourly (boolean), run daily (boolean), force usage of creation date on manual runs (boolean)
Code
{% assign back_in_stock_inventory_level = options.back_in_stock_inventory_level__number_required %}
{% assign product_tags_and_maximum_age_in_days = options.product_tags_and_maximum_age_in_days__keyval_number_required %}
{% comment %}{{ options.include_new_products__boolean }}{% endcomment %}
{% comment %}{{ options.run_hourly__boolean }}{% endcomment %}
{% comment %}{{ options.run_daily__boolean }}{% endcomment %}
{% assign force_usage_of_creation_date = options.force_usage_of_creation_date_on_manual_runs__boolean %}

{% assign now_s = "now" | date: "%s" %}

{% if event.topic == "mechanic/user/trigger" or event.topic contains "mechanic/scheduler" %}
  {% comment %}
    -- run bulk op query for all products in the shop, to get inventory, tags, and metafield values
  {% endcomment %}

  {% capture bulk_operation_query %}
    query {
      products {
        edges {
          node {
            __typename
            id
            createdAt
            title
            tags
            totalInventory
            back_in_stock_metafield: metafield(
              namespace: "mechanic"
              key: "back_in_stock_s"
            ) {
              value
            }
            back_in_stock_first_seen_metafield: metafield(
              namespace: "mechanic"
              key: "back_in_stock_first_seen_s"
            ) {
              value
            }
          }
        }
      }
    }
  {% endcapture %}

  {% action "shopify" %}
    mutation {
      bulkOperationRunQuery(
        query: {{ bulk_operation_query | json }}
      ) {
        bulkOperation {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }
  {% endaction %}

{% elsif event.topic == "mechanic/shopify/bulk_operation" %}
  {% if event.preview %}
    {% capture bulkOperation_objects_jsonl %}
      {"id":"gid:\/\/shopify\/Product\/1234567890","back_in_stock_metafield":{"value": {{ now_s | json }}},"back_in_stock_first_seen_metafield":{"value": "1"}}
      {"id":"gid:\/\/shopify\/Product\/2345678901","tags":[{{ product_tags_and_maximum_age_in_days.first.first | json }}],"back_in_stock_first_seen_metafield":{"value": "1"}}
      {"id":"gid:\/\/shopify\/Product\/3456789012","tags":[{{ product_tags_and_maximum_age_in_days.first.first | json }}]}
    {% endcapture %}

    {% assign bulkOperation = hash %}
    {% assign bulkOperation["objects"] = bulkOperation_objects_jsonl | parse_jsonl %}
  {% endif %}

  {% if force_usage_of_creation_date and event.parent.topic == "mechanic/user/trigger" %}
    {% comment %}
      -- set the first seen data to be the created date of each product
      -- this is primarily to support shops which have upgraded from an earlier version of this task and want to preserve existing back in stock tags
    {% endcomment %}

    {% log "This task was run manually with the 'force usage of creation date' option. It will use the creation date of each product to set the 'first seen date' metafield. No tagging operations will be made on this run." %}

    {% assign metafield_inputs = array %}

    {% for product in bulkOperation.objects %}
      {% assign metafield_input = hash %}
      {% assign metafield_input["ownerId"] = product.id %}
      {% assign metafield_input["namespace"] = "mechanic" %}
      {% assign metafield_input["key"] = "back_in_stock_first_seen_s" %}
      {% assign metafield_input["type"] = "number_integer" %}
      {% assign metafield_input["value"] = product.createdAt | date: "%s" | times: 1 | json %}
      {% assign metafield_inputs = metafield_inputs | push: metafield_input %}
    {% endfor %}

    {% assign groups_of_metafield_inputs = metafield_inputs | in_groups_of: 25, fill_with: false %}

    {% for group_of_metafield_inputs in groups_of_metafield_inputs %}
      {% action "shopify" %}
        mutation {
          metafieldsSet(
            metafields: {{ group_of_metafield_inputs | graphql_arguments }}
          ) {
            userErrors {
              code
              field
              message
            }
          }
        }
      {% endaction %}
    {% endfor %}

    {% break %}
  {% endif %}

  {% comment %}
    -- loop through products and decide which tags should be added/removed based only on metafield values; inventory is not checked here
  {% endcomment %}

  {% assign first_seen_product_ids = array %}

  {% for product in bulkOperation.objects %}
    {% comment %}
      -- check if this product has been seen by this task before; if not, save the ID and move to next product
    {% endcomment %}

    {% if product.back_in_stock_first_seen_metafield == blank %}
      {% assign first_seen_product_ids = first_seen_product_ids | push: product.id %}
      {% continue %}
    {% endif %}

    {% assign back_in_stock_s = product.back_in_stock_metafield.value | times: 1 %}
    {% assign back_in_stock_first_seen_s = product.back_in_stock_first_seen_metafield.value | times: 1 %}

    {% assign do_not_add_tags = nil %}

    {% comment %}
      -- don't add tags unless the back in stock timestamp value is newer than the first seen timestamp
    {% endcomment %}

    {% unless back_in_stock_s > back_in_stock_first_seen_s %}
      {% assign do_not_add_tags = true %}
    {% endunless %}

    {% comment %}
      -- check the threshold times for each tag
    {% endcomment %}

    {% assign tags_to_add = array %}
    {% assign tags_to_remove = array %}

    {% for keyval in product_tags_and_maximum_age_in_days %}
      {% assign tag = keyval[0] %}
      {% assign maximum_age_s = keyval[1] | times: 86400 %}
      {% assign threshold_timestamp_s = now_s | minus: maximum_age_s %}

      {% if back_in_stock_s >= threshold_timestamp_s %}
        {% unless product.tags contains tag or do_not_add_tags %}
          {% assign tags_to_add[tags_to_add.size] = tag %}
        {% endunless %}

      {% else %}
        {% comment %}
          -- back in stock metafield doesn't exist or the value doesn't exceed the threshold; remove tag if it is present
        {% endcomment %}

        {% if product.tags contains tag %}
          {% assign tags_to_remove[tags_to_remove.size] = tag %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {% if tags_to_add != blank or tags_to_remove != blank %}
      {% action "shopify" %}
        mutation {
          {% if tags_to_add != blank %}
            tagsAdd(
              id: {{ product.id | json }}
              tags: {{ tags_to_add | json }}
            ) {
              userErrors {
                field
                message
              }
            }
          {% endif %}

          {% if tags_to_remove != blank %}
            tagsRemove(
              id: {{ product.id | json }}
              tags: {{ tags_to_remove | json }}
            ) {
              userErrors {
                field
                message
              }
            }
          {% endif %}
        }
      {% endaction %}
    {% endif %}
  {% endfor %}

  {% comment %}
    -- set a metafield timestamp value of "now" on any products that were first seen on this run
  {% endcomment %}

  {% if first_seen_product_ids != blank %}
    {% log
      message: "Products first seen on this task run, which will have a first seen metafield set, but will not have new tags added.",
      first_seen_product_ids: first_seen_product_ids
    %}

    {% assign groups_of_product_ids = first_seen_product_ids | in_groups_of: 25, fill_with: false %}

    {% for group_of_product_ids in groups_of_product_ids %}
      {% action "shopify" %}
        mutation {
          metafieldsSet(
            metafields: [
              {% for product_id in group_of_product_ids %}
                {
                  ownerId: {{ product_id | json }}
                  namespace: "mechanic"
                  key: "back_in_stock_first_seen_s"
                  type: "number_integer"
                  value: {{ now_s | json }}
                }
              {% endfor %}
            ]
          ) {
            userErrors {
              code
              field
              message
            }
          }
        }
      {% endaction %}
    {% endfor %}
  {% endif %}

{% elsif event.topic contains "shopify/inventory_levels/" %}
  {% comment %}
    -- on inventory level changes, check the total inventory and set metafields as needed; no tagging decisions occur here
  {% endcomment %}

  {% capture query %}
    query {
      inventoryLevel(id: {{ inventory_level.admin_graphql_api_id | json }}) {
        item {
          variant {
            product {
              id
              totalInventory
              back_in_stock_metafield: metafield(
                namespace: "mechanic"
                key: "back_in_stock_s"
              ) {
                id
                value
              }
              back_in_stock_first_seen_metafield: metafield(
                namespace: "mechanic"
                key: "back_in_stock_first_seen_s"
              ) {
                id
                value
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "inventoryLevel": {
            "item": {
              "variant": {
                "product": {
                  "id": "gid://shopify/Product/1234567890",
                  "totalInventory": {{ back_in_stock_inventory_level }}
                }
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% assign product = result.data.inventoryLevel.item.variant.product %}

  {% log product: product, back_in_stock_inventory_level: back_in_stock_inventory_level %}

  {% assign metafields_to_set = array %}

  {% comment %}
    -- save the first seen time in a metafield if it doesn't already exist
  {% endcomment %}

  {% if product.back_in_stock_first_seen_metafield == blank %}
    {% assign metafield_to_set = hash %}
    {% assign metafield_to_set["ownerId"] = product.id %}
    {% assign metafield_to_set["namespace"] = "mechanic" %}
    {% assign metafield_to_set["key"] = "back_in_stock_first_seen_s" %}
    {% assign metafield_to_set["type"] = "number_integer" %}
    {% assign metafield_to_set["value"] = now_s %}
    {% assign metafields_to_set = metafields_to_set | push: metafield_to_set %}
  {% endif %}

  {% if product.totalInventory >= back_in_stock_inventory_level %}
    {% comment %}
      -- product inventory meets the back in stock threshold; save the time in the metafield IF a value does not already exist
    {% endcomment %}

    {% if product.back_in_stock_metafield == blank %}
      {% assign metafield_to_set = hash %}
      {% assign metafield_to_set["ownerId"] = product.id %}
      {% assign metafield_to_set["namespace"] = "mechanic" %}
      {% assign metafield_to_set["key"] = "back_in_stock_s" %}
      {% assign metafield_to_set["type"] = "number_integer" %}
      {% assign metafield_to_set["value"] = now_s %}
      {% assign metafields_to_set = metafields_to_set | push: metafield_to_set %}
    {% endif %}

  {% elsif product.back_in_stock_metafield != blank  %}
    {% comment %}
      -- product inventory is less than the back in stock threshold and the back in stock metafield exists; delete it
    {% endcomment %}

    {% action "shopify" %}
      mutation {
        metafieldsDelete(
          metafields: [
            {
              ownerId: {{ product.id | json }}
              namespace: "mechanic"
              key: "back_in_stock_s"
            }
          ]
        ) {
          deletedMetafields {
            ownerId
            namespace
            key
          }
          userErrors {
            field
            message
          }
        }
      }
    {% endaction %}
  {% endif %}

  {% if metafields_to_set != blank %}
    {% action "shopify" %}
      mutation {
        metafieldsSet(
          metafields: {{ metafields_to_set | graphql_arguments }}
        ) {
          metafields {
            id
            namespace
            key
            type
            value
            owner {
              ... on Product {
                id
                title
              }
            }
          }
          userErrors {
            code
            field
            message
          }
        }
      }
    {% endaction %}
  {% endif %}

{% elsif event.topic contains "shopify/products/create" %}
  {% comment %}
    -- for new products, set the first seen metafield value using the product created date
    -- tag conditions will be checked on the next inventory update or scheduled task run (if enabled)
  {% endcomment %}

  {% action "shopify" %}
    mutation {
      metafieldsSet(
        metafields: [
          {
            ownerId: {{ product.admin_graphql_api_id | json }}
            namespace: "mechanic"
            key: "back_in_stock_first_seen_s"
            type: "number_integer"
            value: {{ product.created_at | date: "%s" | json }}
          }
        ]
      ) {
        userErrors {
          code
          field
          message
        }
      }
    }
  {% endaction %}
{% endif %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more
Defaults
Back in stock inventory level
1
Product tags and maximum age in days
{"new-1" => "1", "new-5" => "5"}
Run daily
true