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

Mechanic is an automation development platform for Shopify. :)

Sync order timeline comments to the customer note

by Isaac Bowen (team@usemechanic.com)

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 when a user triggers the task and when a bulk operation finishes. 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. 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, Gurus, 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.

Events
when a user triggers the task (mechanic/user/trigger)
when a bulk operation finishes (mechanic/shopify/bulk_operation)
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)
Script
{% 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 %}
Mechanic tasks are written in Liquid, which makes them easy to write and easy to modify. Learn more about our platform.
Defaults
Add new comments to the beginning
true
Comment date format
%d/%m/%y