diff --git a/docs/README.md b/docs/README.md index 292466e3..3d91bc17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -938,6 +938,7 @@ This directory is built automatically. Each task's documentation is generated fr ### In stock * [Add Option Name as a Variant Metafield for In Stock Variants](./add-option-names-as-variant-metafields-for-in-stock-variants) +* [Auto-tag new products by "back in stock" age](./auto-tag-new-products-by-back-in-stock-age) * [Publish back-in-stock products](./publish-back-in-stock-products) * [Tag products as in- or out-of-stock, by location ID](./tag-products-as-in-or-out-of-stock-by-location-id) * [Tag products as in- or out-of-stock](./tag-products-as-in-or-out-of-stock) @@ -1063,7 +1064,6 @@ This directory is built automatically. Each task's documentation is generated fr * [Add Option Name as a Variant Metafield for In Stock Variants](./add-option-names-as-variant-metafields-for-in-stock-variants) * [Auto-associate variants with a delivery profile, by metafield value](./auto-associate-variants-with-a-delivery-profile-by-metafield-value) * [Auto-copy customer metafields to new orders](./auto-copy-customer-metafields-to-new-orders) -* [Auto-tag new products by "back in stock" age](./auto-tag-new-products-by-back-in-stock-age) * [Copy draft order metafields to orders](./copy-draft-order-metafields-to-orders) * [Copy prefixed tags to metafields](./copy-prefixed-tags-to-metafields) * [Copy product metafields to each product's tags](./copy-product-metafields-to-each-products-tags) diff --git a/docs/auto-tag-new-products-by-back-in-stock-age/README.md b/docs/auto-tag-new-products-by-back-in-stock-age/README.md index 90f3fea4..e983faaf 100644 --- a/docs/auto-tag-new-products-by-back-in-stock-age/README.md +++ b/docs/auto-tag-new-products-by-back-in-stock-age/README.md @@ -1,6 +1,6 @@ # Auto-tag new products by "back in stock" age -Tags: Auto-Tag, Metafields, Products, Watch +Tags: Auto-Tag, In stock, Products, Watch 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. @@ -12,11 +12,10 @@ This task monitors changes to inventory level, records the time at which a produ ```json { - "back_in_stock_inventory_level__number_required": "50", + "back_in_stock_inventory_level__number_required": "1", "product_tags_and_maximum_age_in_days__keyval_number_required": { "new-1": "1", - "new-5": "5", - "new-9999": "9999" + "new-5": "5" }, "run_daily__boolean": true, "run_hourly__boolean": false @@ -28,13 +27,15 @@ This task monitors changes to inventory level, records the time at which a produ ## Subscriptions ```liquid +shopify/products/create 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 - -{% if options.run_daily__boolean %}mechanic/scheduler/daily{% endif %} -{% if options.run_hourly__boolean %}mechanic/scheduler/hourly{% endif %} ``` [Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) @@ -45,11 +46,13 @@ This task monitors changes to inventory level, records the time at which a produ 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, a [metafield](https://help.shopify.com/en/manual/metafields) will be added to your product, indicating when it returned to "back in stock" status. (Note: when this task encounters a product for the first time, and the product is _already_ considered "back in stock", the task will record the current time.) +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. -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 Shopify account, 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. -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 added to all products that were created/published 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". For newly created products, the "first seen time" will instead be set to the product creation date, which will ensure that the product's initial "back in stock" time will always be newer. ## Installing this task diff --git a/docs/auto-tag-new-products-by-back-in-stock-age/script.liquid b/docs/auto-tag-new-products-by-back-in-stock-age/script.liquid index 6f81416b..9ff53c8c 100644 --- a/docs/auto-tag-new-products-by-back-in-stock-age/script.liquid +++ b/docs/auto-tag-new-products-by-back-in-stock-age/script.liquid @@ -1,4 +1,13 @@ +{% 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 %} + +{% 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 { @@ -6,14 +15,21 @@ node { __typename id + title tags totalInventory - metafield( + 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 + } } } } @@ -36,62 +52,82 @@ } } {% 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"] = array %} - - {% assign bulkOperation["objects"][0] = hash %} - {% assign bulkOperation["objects"][0]["id"] = "gid://shopify/Product/1234567890" %} - {% assign bulkOperation["objects"][0]["totalInventory"] = options.back_in_stock_inventory_level__number_required | plus: 10 %} - {% assign bulkOperation["objects"][0]["tags"] = array %} - {% assign bulkOperation["objects"][0]["metafield"] = hash %} - {% assign bulkOperation["objects"][0]["metafield"]["value"] = "now" | date: "%s" | minus: 1000 %} - - {% assign bulkOperation["objects"][1] = hash %} - {% assign bulkOperation["objects"][1]["id"] = "gid://shopify/Product/2345678901" %} - {% assign bulkOperation["objects"][1]["totalInventory"] = options.back_in_stock_inventory_level__number_required | minus: 10 %} - {% assign bulkOperation["objects"][1]["tags"] = array %} - {% assign bulkOperation["objects"][1]["tags"][0] = options.product_tags_and_maximum_age_in_days__keyval_number_required.first.first %} - {% assign bulkOperation["objects"][1]["metafield"] = nil %} + {% assign bulkOperation["objects"] = bulkOperation_objects_jsonl | parse_jsonl %} {% endif %} - {% assign now_s = "now" | date: "%s" | times: 1 %} + {% comment %} + -- loop through products and decide which tags should be added/removed based only on metafield values; inventory is not checked here + {% endcomment %} - {% for object in bulkOperation.objects %} - {% assign product_timestamp_s = object.metafield.value | times: 1 %} + {% 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 options.product_tags_and_maximum_age_in_days__keyval_number_required %} + {% for keyval in product_tags_and_maximum_age_in_days %} {% assign tag = keyval[0] %} - {% assign maximum_age_d = keyval[1] %} - {% assign maximum_age_s = maximum_age_d | times: 24 | times: 60 | times: 60 %} - + {% assign maximum_age_s = keyval[1] | times: 86400 %} {% assign threshold_timestamp_s = now_s | minus: maximum_age_s %} - {% comment %} - 0 happens when we didn't have a valid datetime string to begin with. Happens - when there's no published_at value. - {% endcomment %} - {% if product_timestamp_s != 0 and product_timestamp_s > threshold_timestamp_s %} - {% unless object.tags contains tag %} + {% 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 %} - {% if object.tags contains tag %} + {% 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 != empty or tags_to_remove != empty %} + {% if tags_to_add != blank or tags_to_remove != blank %} {% action "shopify" %} mutation { - {% if tags_to_add != empty %} + {% if tags_to_add != blank %} tagsAdd( - id: {{ object.id | json }} + id: {{ product.id | json }} tags: {{ tags_to_add | json }} ) { userErrors { @@ -101,9 +137,9 @@ } {% endif %} - {% if tags_to_remove != empty %} + {% if tags_to_remove != blank %} tagsRemove( - id: {{ object.id | json }} + id: {{ product.id | json }} tags: {{ tags_to_remove | json }} ) { userErrors { @@ -116,7 +152,51 @@ {% 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 }}) { @@ -125,13 +205,20 @@ product { id totalInventory - metafield( + 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 + } } } } @@ -150,8 +237,7 @@ "variant": { "product": { "id": "gid://shopify/Product/1234567890", - "totalInventory": {{ options.back_in_stock_inventory_level__number_required | plus: 1 }}, - "metafield": null + "totalInventory": {{ back_in_stock_inventory_level }} } } } @@ -163,24 +249,49 @@ {% assign result = result_json | parse_json %} {% endif %} - {% assign productNode = result.data.inventoryLevel.item.variant.product %} + {% assign product = result.data.inventoryLevel.item.variant.product %} + + {% log product: product, back_in_stock_inventory_level: back_in_stock_inventory_level %} + + {% assign metafield_inputs = 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_input = hash %} + {% 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"] = now_s %} + {% assign metafield_inputs = metafield_inputs | push: metafield_input %} + {% endif %} - {% log product: productNode, back_in_stock_inventory_level: options.back_in_stock_inventory_level__number_required %} + {% 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_input = hash %} + {% assign metafield_input["namespace"] = "mechanic" %} + {% assign metafield_input["key"] = "back_in_stock_s" %} + {% assign metafield_input["type"] = "number_integer" %} + {% assign metafield_input["value"] = now_s %} + {% assign metafield_inputs = metafield_inputs | push: metafield_input %} + {% 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 %} - {% if productNode.metafield == nil and productNode.totalInventory >= options.back_in_stock_inventory_level__number_required %} {% action "shopify" %} mutation { - productUpdate( + metafieldDelete( input: { - id: {{ productNode.id | json }} - metafields: [ - { - namespace: "mechanic" - key: "back_in_stock_s" - type: "number_integer" - value: {{ "now" | date: "%s" | json }} - } - ] + id: {{ product.back_in_stock_metafield.id | json }} } ) { userErrors { @@ -190,12 +301,15 @@ } } {% endaction %} - {% elsif productNode.metafield != nil and productNode.totalInventory < options.back_in_stock_inventory_level__number_required %} + {% endif %} + + {% if metafield_inputs != blank %} {% action "shopify" %} mutation { - metafieldDelete( + productUpdate( input: { - id: {{ productNode.metafield.id | json }} + id: {{ product.id | json }} + metafields: {{ metafield_inputs | graphql_arguments }} } ) { userErrors { @@ -206,4 +320,31 @@ } {% endaction %} {% endif %} + +{% elsif event.topic contains "shopify/products/create" %} + {% comment %} + -- for new products, set the first seen metafield value using the product created date + {% 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 %} diff --git a/tasks/auto-tag-new-products-by-back-in-stock-age.json b/tasks/auto-tag-new-products-by-back-in-stock-age.json index f3656b49..4e15788a 100644 --- a/tasks/auto-tag-new-products-by-back-in-stock-age.json +++ b/tasks/auto-tag-new-products-by-back-in-stock-age.json @@ -1,31 +1,31 @@ { - "docs": "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.\n\nThis task operates in two phases.\n\nPhase one runs in response to inventory level changes. When a change brings a product's total inventory to at or above your configured threshold, a [metafield](https://help.shopify.com/en/manual/metafields) will be added to your product, indicating when it returned to \"back in stock\" status. (Note: when this task encounters a product for the first time, and the product is _already_ considered \"back in stock\", the task will record the current time.)\n\nPhase 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 Shopify account, and updates tags in bulk, according to the task's configuration and according to the recorded \"back in stock\" time for each product.\n\nConfigure \"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 added to all products that were created/published within the last 5 days. Once a product ages beyond that threshold, this task will remove that tag during the next \"phase two\" run.", + "docs": "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.\n\nThis task operates in two phases.\n\nPhase 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.\n\nPhase 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.\n\nConfigure \"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.\n\n**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\". For newly created products, the \"first seen time\" will instead be set to the product creation date, which will ensure that the product's initial \"back in stock\" time will always be newer.", "halt_action_run_sequence_on_error": false, "name": "Auto-tag new products by \"back in stock\" age", "online_store_javascript": null, "options": { - "back_in_stock_inventory_level__number_required": "50", + "back_in_stock_inventory_level__number_required": "1", "product_tags_and_maximum_age_in_days__keyval_number_required": { "new-1": "1", - "new-5": "5", - "new-9999": "9999" + "new-5": "5" }, "run_daily__boolean": true, "run_hourly__boolean": false }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler\" %}\n {% capture bulk_operation_query %}\n query {\n products {\n edges {\n node {\n __typename\n id\n tags\n totalInventory\n metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_s\"\n ) {\n value\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% if event.preview %}\n {% assign bulkOperation = hash %}\n {% assign bulkOperation[\"objects\"] = array %}\n\n {% assign bulkOperation[\"objects\"][0] = hash %}\n {% assign bulkOperation[\"objects\"][0][\"id\"] = \"gid://shopify/Product/1234567890\" %}\n {% assign bulkOperation[\"objects\"][0][\"totalInventory\"] = options.back_in_stock_inventory_level__number_required | plus: 10 %}\n {% assign bulkOperation[\"objects\"][0][\"tags\"] = array %}\n {% assign bulkOperation[\"objects\"][0][\"metafield\"] = hash %}\n {% assign bulkOperation[\"objects\"][0][\"metafield\"][\"value\"] = \"now\" | date: \"%s\" | minus: 1000 %}\n\n {% assign bulkOperation[\"objects\"][1] = hash %}\n {% assign bulkOperation[\"objects\"][1][\"id\"] = \"gid://shopify/Product/2345678901\" %}\n {% assign bulkOperation[\"objects\"][1][\"totalInventory\"] = options.back_in_stock_inventory_level__number_required | minus: 10 %}\n {% assign bulkOperation[\"objects\"][1][\"tags\"] = array %}\n {% assign bulkOperation[\"objects\"][1][\"tags\"][0] = options.product_tags_and_maximum_age_in_days__keyval_number_required.first.first %}\n {% assign bulkOperation[\"objects\"][1][\"metafield\"] = nil %}\n {% endif %}\n\n {% assign now_s = \"now\" | date: \"%s\" | times: 1 %}\n\n {% for object in bulkOperation.objects %}\n {% assign product_timestamp_s = object.metafield.value | times: 1 %}\n\n {% assign tags_to_add = array %}\n {% assign tags_to_remove = array %}\n\n {% for keyval in options.product_tags_and_maximum_age_in_days__keyval_number_required %}\n {% assign tag = keyval[0] %}\n {% assign maximum_age_d = keyval[1] %}\n {% assign maximum_age_s = maximum_age_d | times: 24 | times: 60 | times: 60 %}\n\n {% assign threshold_timestamp_s = now_s | minus: maximum_age_s %}\n\n {% comment %}\n 0 happens when we didn't have a valid datetime string to begin with. Happens\n when there's no published_at value.\n {% endcomment %}\n {% if product_timestamp_s != 0 and product_timestamp_s > threshold_timestamp_s %}\n {% unless object.tags contains tag %}\n {% assign tags_to_add[tags_to_add.size] = tag %}\n {% endunless %}\n {% else %}\n {% if object.tags contains tag %}\n {% assign tags_to_remove[tags_to_remove.size] = tag %}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {% if tags_to_add != empty or tags_to_remove != empty %}\n {% action \"shopify\" %}\n mutation {\n {% if tags_to_add != empty %}\n tagsAdd(\n id: {{ object.id | json }}\n tags: {{ tags_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n\n {% if tags_to_remove != empty %}\n tagsRemove(\n id: {{ object.id | json }}\n tags: {{ tags_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n {% endfor %}\n{% elsif event.topic contains \"shopify/inventory_levels/\" %}\n {% capture query %}\n query {\n inventoryLevel(id: {{ inventory_level.admin_graphql_api_id | json }}) {\n item {\n variant {\n product {\n id\n totalInventory\n metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_s\"\n ) {\n id\n value\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"inventoryLevel\": {\n \"item\": {\n \"variant\": {\n \"product\": {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"totalInventory\": {{ options.back_in_stock_inventory_level__number_required | plus: 1 }},\n \"metafield\": null\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign productNode = result.data.inventoryLevel.item.variant.product %}\n\n {% log product: productNode, back_in_stock_inventory_level: options.back_in_stock_inventory_level__number_required %}\n\n {% if productNode.metafield == nil and productNode.totalInventory >= options.back_in_stock_inventory_level__number_required %}\n {% action \"shopify\" %}\n mutation {\n productUpdate(\n input: {\n id: {{ productNode.id | json }}\n metafields: [\n {\n namespace: \"mechanic\"\n key: \"back_in_stock_s\"\n type: \"number_integer\"\n value: {{ \"now\" | date: \"%s\" | json }}\n }\n ]\n }\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% elsif productNode.metafield != nil and productNode.totalInventory < options.back_in_stock_inventory_level__number_required %}\n {% action \"shopify\" %}\n mutation {\n metafieldDelete(\n input: {\n id: {{ productNode.metafield.id | json }}\n }\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endif %}", + "script": "{% assign back_in_stock_inventory_level = options.back_in_stock_inventory_level__number_required %}\n{% assign product_tags_and_maximum_age_in_days = options.product_tags_and_maximum_age_in_days__keyval_number_required %}\n\n{% assign now_s = \"now\" | date: \"%s\"%}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler\" %}\n {% comment %}\n -- run bulk op query for all products in the shop, to get inventory, tags, and metafield values\n {% endcomment %}\n\n {% capture bulk_operation_query %}\n query {\n products {\n edges {\n node {\n __typename\n id\n title\n tags\n totalInventory\n back_in_stock_metafield: metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_s\"\n ) {\n value\n }\n back_in_stock_first_seen_metafield: metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_first_seen_s\"\n ) {\n value\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% if event.preview %}\n {% capture bulkOperation_objects_jsonl %}\n {\"id\":\"gid:\\/\\/shopify\\/Product\\/1234567890\",\"back_in_stock_metafield\":{\"value\": {{ now_s | json }}},\"back_in_stock_first_seen_metafield\":{\"value\": \"1\"}}\n {\"id\":\"gid:\\/\\/shopify\\/Product\\/2345678901\",\"tags\":[{{ product_tags_and_maximum_age_in_days.first.first | json }}],\"back_in_stock_first_seen_metafield\":{\"value\": \"1\"}}\n {\"id\":\"gid:\\/\\/shopify\\/Product\\/3456789012\",\"tags\":[{{ product_tags_and_maximum_age_in_days.first.first | json }}]}\n {% endcapture %}\n\n {% assign bulkOperation = hash %}\n {% assign bulkOperation[\"objects\"] = bulkOperation_objects_jsonl | parse_jsonl %}\n {% endif %}\n\n {% comment %}\n -- loop through products and decide which tags should be added/removed based only on metafield values; inventory is not checked here\n {% endcomment %}\n\n {% assign first_seen_product_ids = array %}\n\n {% for product in bulkOperation.objects %}\n {% comment %}\n -- check if this product has been seen by this task before; if not, save the ID and move to next product\n {% endcomment %}\n\n {% if product.back_in_stock_first_seen_metafield == blank %}\n {% assign first_seen_product_ids = first_seen_product_ids | push: product.id %}\n {% continue %}\n {% endif %}\n\n {% assign back_in_stock_s = product.back_in_stock_metafield.value | times: 1 %}\n {% assign back_in_stock_first_seen_s = product.back_in_stock_first_seen_metafield.value | times: 1 %}\n\n {% assign do_not_add_tags = nil %}\n\n {% comment %}\n -- don't add tags unless the back in stock timestamp value is newer than the first seen timestamp\n {% endcomment %}\n\n {% unless back_in_stock_s > back_in_stock_first_seen_s %}\n {% assign do_not_add_tags = true %}\n {% endunless %}\n\n {% comment %}\n -- check the threshold times for each tag\n {% endcomment %}\n\n {% assign tags_to_add = array %}\n {% assign tags_to_remove = array %}\n\n {% for keyval in product_tags_and_maximum_age_in_days %}\n {% assign tag = keyval[0] %}\n {% assign maximum_age_s = keyval[1] | times: 86400 %}\n {% assign threshold_timestamp_s = now_s | minus: maximum_age_s %}\n\n {% if back_in_stock_s >= threshold_timestamp_s %}\n {% unless product.tags contains tag or do_not_add_tags %}\n {% assign tags_to_add[tags_to_add.size] = tag %}\n {% endunless %}\n\n {% else %}\n {% comment %}\n -- back in stock metafield doesn't exist or the value doesn't exceed the threshold; remove tag if it is present\n {% endcomment %}\n\n {% if product.tags contains tag %}\n {% assign tags_to_remove[tags_to_remove.size] = tag %}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {% if tags_to_add != blank or tags_to_remove != blank %}\n {% action \"shopify\" %}\n mutation {\n {% if tags_to_add != blank %}\n tagsAdd(\n id: {{ product.id | json }}\n tags: {{ tags_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n\n {% if tags_to_remove != blank %}\n tagsRemove(\n id: {{ product.id | json }}\n tags: {{ tags_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- set a metafield timestamp value of \"now\" on any products that were first seen on this run\n {% endcomment %}\n\n {% if first_seen_product_ids != blank %}\n {% log\n message: \"Products first seen on this task run, which will have a first seen metafield set, but will not have new tags added.\",\n first_seen_product_ids: first_seen_product_ids\n %}\n\n {% assign groups_of_product_ids = first_seen_product_ids | in_groups_of: 25, fill_with: false %}\n\n {% for group_of_product_ids in groups_of_product_ids %}\n {% action \"shopify\" %}\n mutation {\n metafieldsSet(\n metafields: [\n {% for product_id in group_of_product_ids %}\n {\n ownerId: {{ product_id | json }}\n namespace: \"mechanic\"\n key: \"back_in_stock_first_seen_s\"\n type: \"number_integer\"\n value: {{ now_s | json }}\n }\n {% endfor %}\n ]\n ) {\n userErrors {\n code\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n {% endif %}\n\n{% elsif event.topic contains \"shopify/inventory_levels/\" %}\n {% comment %}\n -- on inventory level changes, check the total inventory and set metafields as needed; no tagging decisions occur here\n {% endcomment %}\n\n {% capture query %}\n query {\n inventoryLevel(id: {{ inventory_level.admin_graphql_api_id | json }}) {\n item {\n variant {\n product {\n id\n totalInventory\n back_in_stock_metafield: metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_s\"\n ) {\n id\n value\n }\n back_in_stock_first_seen_metafield: metafield(\n namespace: \"mechanic\"\n key: \"back_in_stock_first_seen_s\"\n ) {\n id\n value\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"inventoryLevel\": {\n \"item\": {\n \"variant\": {\n \"product\": {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"totalInventory\": {{ back_in_stock_inventory_level }}\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign product = result.data.inventoryLevel.item.variant.product %}\n\n {% log product: product, back_in_stock_inventory_level: back_in_stock_inventory_level %}\n\n {% assign metafield_inputs = array %}\n\n {% comment %}\n -- save the first seen time in a metafield if it doesn't already exist\n {% endcomment %}\n\n {% if product.back_in_stock_first_seen_metafield == blank %}\n {% assign metafield_input = hash %}\n {% assign metafield_input[\"namespace\"] = \"mechanic\" %}\n {% assign metafield_input[\"key\"] = \"back_in_stock_first_seen_s\" %}\n {% assign metafield_input[\"type\"] = \"number_integer\" %}\n {% assign metafield_input[\"value\"] = now_s %}\n {% assign metafield_inputs = metafield_inputs | push: metafield_input %}\n {% endif %}\n\n {% if product.totalInventory >= back_in_stock_inventory_level %}\n {% comment %}\n -- product inventory meets the back in stock threshold; save the time in the metafield IF a value does not already exist\n {% endcomment %}\n\n {% if product.back_in_stock_metafield == blank %}\n {% assign metafield_input = hash %}\n {% assign metafield_input[\"namespace\"] = \"mechanic\" %}\n {% assign metafield_input[\"key\"] = \"back_in_stock_s\" %}\n {% assign metafield_input[\"type\"] = \"number_integer\" %}\n {% assign metafield_input[\"value\"] = now_s %}\n {% assign metafield_inputs = metafield_inputs | push: metafield_input %}\n {% endif %}\n\n {% elsif product.back_in_stock_metafield != blank %}\n {% comment %}\n -- product inventory is less than the back in stock threshold and the back in stock metafield exists; delete it\n {% endcomment %}\n\n {% action \"shopify\" %}\n mutation {\n metafieldDelete(\n input: {\n id: {{ product.back_in_stock_metafield.id | json }}\n }\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n\n {% if metafield_inputs != blank %}\n {% action \"shopify\" %}\n mutation {\n productUpdate(\n input: {\n id: {{ product.id | json }}\n metafields: {{ metafield_inputs | graphql_arguments }}\n }\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic contains \"shopify/products/create\" %}\n {% comment %}\n -- for new products, set the first seen metafield value using the product created date\n {% endcomment %}\n\n {% action \"shopify\" %}\n mutation {\n metafieldsSet(\n metafields: [\n {\n ownerId: {{ product.admin_graphql_api_id | json }}\n namespace: \"mechanic\"\n key: \"back_in_stock_first_seen_s\"\n type: \"number_integer\"\n value: {{ product.created_at | date: \"%s\" | json }}\n }\n ]\n ) {\n userErrors {\n code\n field\n message\n }\n }\n }\n {% endaction %}\n{% endif %}\n", "subscriptions": [ + "shopify/products/create", "shopify/inventory_levels/update", + "mechanic/scheduler/hourly", "mechanic/user/trigger", - "mechanic/shopify/bulk_operation", - "mechanic/scheduler/daily" + "mechanic/shopify/bulk_operation" ], - "subscriptions_template": "shopify/inventory_levels/update\n\nmechanic/user/trigger\nmechanic/shopify/bulk_operation\n\n{% if options.run_daily__boolean %}mechanic/scheduler/daily{% endif %}\n{% if options.run_hourly__boolean %}mechanic/scheduler/hourly{% endif %}", + "subscriptions_template": "shopify/products/create\nshopify/inventory_levels/update\n{% if options.run_hourly__boolean %}\n mechanic/scheduler/hourly\n{% elsif options.run_daily__boolean %}\n mechanic/scheduler/daily\n{% endif %}\nmechanic/user/trigger\nmechanic/shopify/bulk_operation", "tags": [ "Auto-Tag", - "Metafields", + "In stock", "Products", "Watch" ]