Sync order timeline comments to the customer note, with Mechanic.

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

Sync order timeline comments to the customer note

This task scans all orders (optionally filtering by the query of your choice), and copies any new timeline comments to the customer's note. Useful for getting a snapshot of order activity when looking at the customer's record. Runs manually, hourly, and/or daily.

Runs Occurs when a user manually triggers the task and Occurs when a bulk operation is completed. Configuration includes only process customers matching this query, add new comments to the beginning, do not add removed comments back in, comment date format, automatically trim notes after a one day warning, send errors to slack, slack incoming webhook url, run daily, and run hourly.

15-day free trial – unlimited tasks

Documentation

This task scans all orders (optionally filtering by the query of your choice), and copies any new timeline comments to the customer's note. Useful for getting a snapshot of order activity when looking at the customer's record. Runs manually, hourly, and/or daily.

This task scans all orders (optionally filtering by the query of your choice), and copies any new timeline comments to the customer's note. Any existing content in the customer note will be preserved; each new comment will be added to the end of the note (or, optionally, to the beginning). Any edited timeline comments will be added as new note lines during the next scan.

Use the "Run task" button to run the scan manually. Or, enable the "Run daily" and/or "Run hourly" options to have Mechanic run this task automatically.

Notes:

  • This task uses bulk operations, and therefore operates in two stages: (a) sending the initial query to Shopify, and then (b) processing the results when they come in, as a new event.
  • If adding new comments would bring the customer note length past the 5000-character limit, this task will either return an error or (if configured) send a Slack message with that error message. If it would require trimming less than 1000 characters to bring the new note within the limit, the task will do for that customer, so the next time the task runs.
  • Mechanic adds a timestamp to the very end of the customer note, allowing it to skip comments it has already seen during the next run. The task will attempt to avoid duplicates without it, but we don't recommend removing it.

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.run_daily__boolean %}mechanic/scheduler/daily{% endif %}
{% if options.run_hourly__boolean %}mechanic/scheduler/hourly{% endif %}
mechanic/user/trigger
mechanic/shopify/bulk_operation
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
only process customers matching this query, add new comments to the beginning (boolean), do not add removed comments back in (boolean), comment date format (required), automatically trim notes after a one day warning (boolean), send errors to slack (boolean), slack incoming webhook url, run daily (boolean), run hourly (boolean)
Code
{% assign mistaken_order_name_constants = array %}
{% assign mistaken_order_name_constants[0] = "" %}
{% assign mistaken_order_name_constants[1] = "N001388836" %}
{% assign mistaken_order_name_constants[2] = "HL244314" %}

{% if event.topic contains "mechanic/scheduler/" or event.topic == "mechanic/user/trigger" %}
  {% capture bulk_operation_query %}
    query {
      customers(
        query: {{ options.only_process_customers_matching_this_query | json }}
      ) {
        edges {
          node {
            __typename
            id
            legacyResourceId
            email
            note
            orders {
              edges {
                node {
                  __typename
                  id
                  name
                  events(
                    query: "verb:comment"
                    sortKey: CREATED_AT
                  ) {
                    edges {
                      node {
                        __typename
                        id
                        createdAt
                        message
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% action "shopify" %}
    mutation {
      bulkOperationRunQuery(
        query: {{ bulk_operation_query | json }}
      ) {
        bulkOperation {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }
  {% endaction %}
{% elsif event.topic == "mechanic/shopify/bulk_operation" %}
  {% assign customers = array %}
  {% assign customer_ids_and_order_comments = hash %}
  {% assign double_newline = newline | append: newline %}

  {% for object in bulkOperation.objects %}
    {% case object.__typename %}
    {% when "CommentEvent" %}
      {% assign customer = object.__parent.__parent %}

      {% if customer_ids_and_order_comments[customer.id] == nil %}
        {% assign customer_ids_and_order_comments[customer.id] = array %}
      {% endif %}

      {% assign order_comment = hash %}
      {% assign order_comment["created_at"] = object.createdAt %}
      {% assign order_comment["message"] = object.message %}
      {% assign order_comment["order_name"] = object.__parent.name %}

      {% assign _order_comments_count = customer_ids_and_order_comments[customer.id].size %}
      {% assign customer_ids_and_order_comments[customer.id][_order_comments_count] = order_comment %}
    {% when "Customer" %}
      {% assign customers[customers.size] = object %}
    {% endcase %}
  {% endfor %}

  {% if event.preview %}
    {% assign customers[0] = '{"id":"gid://shopify/Customer/1234567890","note":"(existing note content)"}' | parse_json %}

    {% assign order_preview_comment = hash %}
    {% assign order_preview_comment["created_at"] = "now" %}
    {% assign order_preview_comment["message"] = "This is a comment that's been left." %}
    {% assign order_preview_comment["order_name"] = "#1234" %}

    {% assign customer_ids_and_order_comments["gid://shopify/Customer/1234567890"] = array %}
    {% assign customer_ids_and_order_comments["gid://shopify/Customer/1234567890"][0] = order_preview_comment %}
  {% endif %}

  {% for customer in customers %}
    {% assign new_order_comments_formatted = array %}
    {% assign reused_note = customer.note %}

    {% comment %}
      If we've previously hit the note's max length, there should be a timestamp recorded in the note,
      marking the point in time from which we should start looking for new comments.
    {% endcomment %}
    {% assign minimum_order_comment_created_at_s = nil %}
    {% assign note_lines = customer.note | split: newline %}
    {% for note_line in note_lines %}
      {% assign note_line_parts = note_line | split: ":" %}
      {% if note_line_parts[0] == "mechanic" and note_line_parts[1] == "timestamp" %}
        {% assign reused_note = reused_note | replace: note_line, "" | strip %}

        {% assign minimum_order_comment_created_at_s = note_line_parts[2] | times: 1 %}
        {% if minimum_order_comment_created_at_s == 0 %}
          {% assign minimum_order_comment_created_at_s = nil %}
        {% else %}
          {% break %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {% assign newest_order_comment_created_at_s = nil %}

    {% assign order_comments_sorted = customer_ids_and_order_comments[customer.id] | sort: "created_at" %}
    {% if options.add_new_comments_to_the_beginning__boolean %}
      {% assign order_comments_sorted = order_comments_sorted | reverse %}
    {% endif %}

    {% for order_comment in order_comments_sorted %}
      {% assign order_comment_created_at_s = order_comment.created_at | date: "%s" | times: 1 %}

      {% if options.do_not_add_removed_comments_back_in__boolean %}
        {% if minimum_order_comment_created_at_s != nil and minimum_order_comment_created_at_s >= order_comment_created_at_s %}
          {% continue %}
        {% endif %}
      {% endif %}

      {% if newest_order_comment_created_at_s == nil or order_comment_created_at_s > newest_order_comment_created_at_s %}
        {% assign newest_order_comment_created_at_s = order_comment_created_at_s %}
      {% endif %}

      {% assign order_comment_message_formatted = order_comment.message | strip | strip_html | prepend: '"' | append: '"' %}
      {% assign order_comment_date_formatted = order_comment.created_at | date: options.comment_date_format__required %}

      {% assign order_comment_formatted = order_comment_date_formatted | append: " - " | append: order_comment_message_formatted | append: " (" | append: order_comment.order_name | append: ")" %}

      {% comment %}fix up places where we mistakenly used a constant order name{% endcomment %}
      {% assign tail_with_order_name = '" (' | append: order_comment.order_name | append: ")" %}
      {% for mistaken_order_name_constant in mistaken_order_name_constants %}
        {% assign tail_with_constant = '" (' | append: mistaken_order_name_constant | append: ')' %}
        {% assign order_comment_formatted_with_constant_tail = order_comment_formatted | replace: tail_with_order_name, tail_with_constant %}
        {% assign reused_note = reused_note | replace: order_comment_formatted_with_constant_tail, order_comment_formatted %}
      {% endfor %}

      {% if reused_note contains order_comment_formatted %}
        {% assign reused_note_pieces = reused_note | split: order_comment_formatted %}

        {% if reused_note_pieces.size > 2 %}
          {% comment %}remove duplicates{% endcomment %}
          {% assign replacement_note_pieces = array %}

          {% for piece in reused_note_pieces %}
            {% unless piece == blank %}
              {% assign replacement_note_pieces[replacement_note_pieces.size] = piece | strip %}
            {% endunless %}

            {% if options.add_new_comments_to_the_beginning__boolean == true and forloop.last %}
              {% comment %}remove all but the last{% endcomment %}
              {% assign replacement_note_pieces[replacement_note_pieces.size] = order_comment_formatted %}
            {% elsif options.add_new_comments_to_the_beginning__boolean == false and forloop.first %}
              {% comment %}remove all but the first{% endcomment %}
              {% assign replacement_note_pieces[replacement_note_pieces.size] = order_comment_formatted %}
            {% endif %}
          {% endfor %}

          {% assign reused_note = replacement_note_pieces | join: double_newline %}
        {% endif %}
      {% else %}
        {% assign new_order_comments_formatted[new_order_comments_formatted.size] = order_comment_formatted | strip %}
      {% endif %}
    {% endfor %}

    {% if options.add_new_comments_to_the_beginning__boolean %}
      {% assign updated_note = new_order_comments_formatted | join: double_newline | append: double_newline | append: reused_note | strip %}
    {% else %}
      {% assign updated_note = new_order_comments_formatted | join: double_newline | prepend: double_newline | prepend: reused_note | strip %}
    {% endif %}

    {% comment %}
      We perform this check twice. Here, it matters because we've added any new comments we wish to add.
      If none have actually been added, we can safely bail.
    {% endcomment %}
    {% if updated_note == customer.note %}
      {% if options.only_process_customers_matching_this_query != blank %}
        {"log": {{ customer.email | append: ": Customer note is unchanged; no new order comments found, and there was no timestamp on the note" | json }}}
      {% endif %}
      {% continue %}
    {% endif %}

    {% assign cache_key = "flag-customer-for-note-trimming:" | append: customer.id %}
    {% assign cancel_update = false %}

    {% if updated_note.size > 5000 and options.automatically_trim_notes_after_a_one_day_warning__boolean == false %}
      {% comment %}can't handle this automatically; alert slack{% endcomment %}
      {% assign cancel_update = true %}
      {% assign cancel_message = "could not have new order timeline comments added to their note; the new note would exceed 5000 characters" %}
    {% elsif updated_note.size > 6000 %}
      {% comment %}can't handle this automatically; alert slack{% endcomment %}
      {% assign cancel_update = true %}
      {% assign cancel_message = "could not have new order timeline comments added to their note; will not retry with trimming, since trimming would remove more than 1000 characters" %}
    {% elsif updated_note.size > 5000 and cache[cache_key] != true %}
      {% assign two_days_s = 60 | times: 60 | times: 24 | times: 2 %}
      {% action "cache", "setex", cache_key, two_days_s, true %}
      {% assign cancel_update = true %}
      {% comment %}will attempt to handle this automatically next time; alert slack{% endcomment %}
      {% assign cancel_message = "could not have new order timeline comments added to their note; will trim down to 5000 characters during the next scan" %}
    {% else %}
      {% comment %}
        If we're here, we're either under the character limit, or we're over it *and* we're clear to
        trim it. We add a timestamp noting the creation time of the newest comment; the next time we
        scan, we'll only look at comments that arrive after this timestamp.
      {% endcomment %}
      {% assign timestamp_line = newest_order_comment_created_at_s | default: minimum_order_comment_created_at_s | prepend: "mechanic:timestamp:" %}

      {% comment %}
        Perform slicing if necessary, but only if necessary - this action is destructive for strings
        under the limit, too.
      {% endcomment %}
      {% if updated_note.size > 5000 %}
        {% comment %}
          The timestamp line is 29 characters, plus a double newline is 31. From 5000, that leaves 4969.
          We trim *away* from the spot where new comments are added.
        {% endcomment %}
        {% if options.add_new_comments_to_the_beginning__boolean %}
          {% assign updated_note = updated_note | slice: 0, 4969 %}
        {% else %}
          {% assign updated_note = updated_note | slice: -4969, 4969 %}
        {% endif %}
      {% endif %}

      {% assign updated_note = updated_note | append: double_newline | append: timestamp_line %}

      {% if cache[cache_key] %}
        {% comment %}
          This approval is only good once. If we're here, and this approval exists, we've used it and
          should clear it at this time.
        {% endcomment %}
        {% action "cache", "del", cache_key %}
      {% endif  %}
    {% endif %}

    {% comment %}
      The second check. Here, it matters because we've executed on any trimming-related activity
      necessary, and the result may not actually differ from the original note, rendering an update moot.
    {% endcomment %}
    {% if updated_note == customer.note %}
      {% if options.only_process_customers_matching_this_query != blank %}
        {"log": {{ customer.email | append: ": Customer note is unchanged" | json }}}
      {% endif %}
      {% continue %}
    {% endif %}

    {% if cancel_update %}
      {
        "log": {
          "customer_id": {{ customer.id | json }},
          "order_comments": {{ order_comments_sorted | json }},
          "existing_note": {{ customer.note | json }},
          "existing_note_size": {{ customer.note.size | json }},
          "updated_note": {{ updated_note | json }},
          "updated_note_size": {{ updated_note.size | json }}
        }
      }

      {% assign message = "Notice for " | append: customer.email | append: ": " | append: cancel_message | append: newline | append: newline | append: "Manage this customer in Shopify: https://" | append: shop.domain | append: "/admin/customers/" | append: customer.legacyResourceId %}

      {% if options.send_errors_to_slack__boolean and options.slack_incoming_webhook_url != blank %}
        {% action "http" %}
          {
            "method": "post",
            "url": {{ options.slack_incoming_webhook_url | json }},
            "body": {
              "text": {{ message | json }}
            }
          }
        {% endaction %}
      {% else %}
        {% action "echo" %}
          {"error": {{ message | json  }}}
        {% endaction %}
      {% endif %}
    {% else %}
      {% comment %}
        Just in case something goes horribly wrong, we record the existing customer note. :)
      {% endcomment %}
      {"log": {"customer_id": {{ customer.id | json }}, "existing_note": {{ customer.note | json }}}}

      {% action "shopify" %}
        mutation {
          customerUpdate(
            input: {
              id: {{ customer.id | json }}
              note: {{ updated_note | json }}
            }
          ) {
            customer {
              note
            }
            userErrors{
              field
              message
            }
          }
        }
      {% endaction %}
    {% endif %}
  {% endfor %}
{% endif %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more
Defaults
Add new comments to the beginning
true
Comment date format
%d/%m/%y