-
Notifications
You must be signed in to change notification settings - Fork 51
feat: inkthreadable #701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: inkthreadable #701
Changes from 7 commits
d214144
1fd70d5
514cd29
5b7f30b
420af97
bd6ae76
4956398
f1551f2
93dc8ca
99cf6f3
31ad2ab
6fc39e0
f8658c3
2dd5495
b5eb708
18e5a23
e8fbad9
48b126e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,77 @@ | ||||||
| # frozen_string_literal: true | ||||||
|
|
||||||
| class Shop::SendInkthreadableOrderJob < ApplicationJob | ||||||
| queue_as :default | ||||||
|
|
||||||
| def perform(order_id) | ||||||
| order = ShopOrder.find(order_id) | ||||||
| shop_item = order.shop_item | ||||||
|
|
||||||
| unless shop_item.is_a?(ShopItem::InkthreadableItem) | ||||||
| Rails.logger.warn "[Inkthreadable] Order #{order_id} is not an Inkthreadable item, skipping" | ||||||
| return | ||||||
| end | ||||||
|
|
||||||
| address = order.frozen_address | ||||||
| if address.blank? | ||||||
| Rails.logger.error "[Inkthreadable] Order #{order_id} missing address" | ||||||
| return | ||||||
| end | ||||||
|
|
||||||
| payload = build_order_payload(order, shop_item, address) | ||||||
|
|
||||||
| response = InkthreadableService.create_order(payload) | ||||||
|
|
||||||
| inkthreadable_order_id = response.dig("order", "id") | ||||||
| if inkthreadable_order_id.present? | ||||||
| order.update!(external_ref: "INK-#{inkthreadable_order_id}") | ||||||
| Rails.logger.info "[Inkthreadable] Order #{order_id} submitted successfully as #{inkthreadable_order_id}" | ||||||
| else | ||||||
| Rails.logger.error "[Inkthreadable] Order #{order_id} response missing order.id: #{response.inspect}" | ||||||
| raise "Inkthreadable response missing order.id" | ||||||
| end | ||||||
| rescue Faraday::Error => e | ||||||
| Rails.logger.error "[Inkthreadable] Failed to send order #{order_id}: #{e.message}" | ||||||
| Rails.logger.error e.response&.dig(:body) if e.respond_to?(:response) | ||||||
| raise | ||||||
| end | ||||||
|
|
||||||
| private | ||||||
|
|
||||||
| def build_order_payload(order, shop_item, address) | ||||||
| payload = { | ||||||
| external_id: "FT-#{order.id}", | ||||||
| shipping_address: { | ||||||
| firstName: address["first_name"] || address["firstName"] || order.user.display_name.split.first, | ||||||
| lastName: address["last_name"] || address["lastName"] || order.user.display_name.split.last, | ||||||
| company: address["company"], | ||||||
| address1: address["address1"] || address["line1"], | ||||||
| address2: address["address2"] || address["line2"], | ||||||
| city: address["city"], | ||||||
| county: address["state"] || address["county"], | ||||||
| postcode: address["postcode"] || address["zip"] || address["postal_code"], | ||||||
| country: address["country"], | ||||||
| phone1: address["phone"] || address["phone1"] | ||||||
| }.compact, | ||||||
|
Comment on lines
+44
to
+55
|
||||||
| shipping: { | ||||||
| shippingMethod: shop_item.shipping_method | ||||||
| }, | ||||||
| items: build_order_items(order, shop_item) | ||||||
| } | ||||||
|
|
||||||
| payload[:brandName] = shop_item.brand_name if shop_item.brand_name.present? | ||||||
| payload[:comment] = "Flavortown order ##{order.id}" if Rails.env.production? | ||||||
|
||||||
| payload[:comment] = "Flavortown order ##{order.id}" if Rails.env.production? | |
| payload[:comment] = "Flavortown order ##{order.id}" |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Shop::SendInkthreadableOrderJob lacks test coverage. Consider adding tests to verify payload construction with various address formats, error handling when addresses are missing required fields, and proper handling of Faraday errors.
NeonGamerBot-QK marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,76 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class Shop::SyncInkthreadableStatusJob < ApplicationJob | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| queue_as :default | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SHIPPED_STATUSES = [ "quality control" ].freeze | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ALERT_SLACK_ID = "U08FDLWUZM4" # @transcental | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
NeonGamerBot-QK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def perform | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pending_inkthreadable_orders.find_each do |order| | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sync_order_status(order) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rescue => e | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Rails.logger.error "[InkthreadableSync] Failed to sync order #{order.id}: #{e.message}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def pending_inkthreadable_orders | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ShopOrder | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .joins(:shop_item) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where(shop_items: { type: "ShopItem::InkthreadableItem" }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where.not(aasm_state: %w[fulfilled rejected refunded]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where("external_ref LIKE 'INK-%'") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def sync_order_status(order) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inkthreadable_id = order.external_ref.delete_prefix("INK-") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response = InkthreadableService.get_order(inkthreadable_id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ink_order = response["order"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return unless ink_order | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = ink_order["status"]&.downcase | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| shipping = ink_order["shipping"] || {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tracking_number = shipping["trackingNumber"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| shipped_at = shipping["shiped_at"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
NeonGamerBot-QK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
NeonGamerBot-QK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if shipped_at.present? || tracking_number.present? | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mark_as_fulfilled(order, tracking_number) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+39
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| elsif status == "refunded" || ink_order["deleted"] == "true" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handle_cancelled(order) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handle_unexpected_status(order, status) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def handle_unexpected_status(order, status) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message = "[InkthreadableSync] Order #{order.id} has unexpected status: #{status}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Rails.logger.warn message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Sentry.capture_message(message, level: :warning, extra: { order_id: order.id, status: status }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| send_slack_alert(order, status) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| send_slack_alert(order, status) | |
| end | |
| send_slack_alert(order, status) if alert_allowed?(order, status) | |
| end | |
| def alert_allowed?(order, status) | |
| cache_key = "inkthreadable_unexpected_status_alert:#{order.id}:#{status}" | |
| data = Rails.cache.read(cache_key) || {} | |
| last_alert_at = data["last_alert_at"] && Time.zone.parse(data["last_alert_at"].to_s) | |
| count = (data["count"] || 0).to_i | |
| # Exponential backoff: 1h, 2h, 4h, 8h, ... capped at 24h | |
| base_interval = 1.hour | |
| interval = [base_interval * (2**count), 24.hours].min | |
| now = Time.current | |
| if last_alert_at.nil? || now - last_alert_at >= interval | |
| Rails.cache.write( | |
| cache_key, | |
| { | |
| "last_alert_at" => now.iso8601, | |
| "count" => count + 1 | |
| }, | |
| expires_in: 7.days | |
| ) | |
| true | |
| else | |
| false | |
| end | |
| end |
Outdated
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL uses a hardcoded hostname "flavortown.hackclub.com" which may not be correct for non-production environments. Consider using a configurable hostname or Rails.application.config.action_mailer.default_url_options[:host] to ensure the correct domain is used in all environments.
| client.chat_postMessage( | |
| channel: slack_id, | |
| text: "⚠️ Inkthreadable order needs attention!\n\nOrder ##{order.id} has unexpected status: *#{status}*\n\nPlease review: #{Rails.application.routes.url_helpers.admin_shop_order_url(order, host: "flavortown.hackclub.com")}" | |
| host = Rails.application.config.action_mailer.default_url_options[:host] | |
| client.chat_postMessage( | |
| channel: slack_id, | |
| text: "⚠️ Inkthreadable order needs attention!\n\nOrder ##{order.id} has unexpected status: *#{status}*\n\nPlease review: #{Rails.application.routes.url_helpers.admin_shop_order_url(order, host: host)}" |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's move this to the shop_order.rb model and use it whenever issues come up! Also going to make it a channel instead of DMing me - invited you on Slack
NeonGamerBot-QK marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handle_cancelled method logs a warning but takes no action on the order. If an order is refunded or deleted in Inkthreadable, the order should likely be refunded or rejected in the local system as well. Consider implementing proper handling such as calling order.refund! or order.mark_rejected! with an appropriate reason.
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Shop::SyncInkthreadableStatusJob lacks test coverage. Consider adding tests for various order states (shipped, refunded, unexpected status), Slack alert behavior, and the query filtering logic for pending orders.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # == Schema Information | ||
| # | ||
| # Table name: shop_items | ||
| # | ||
| # id :bigint not null, primary key | ||
| # accessory_tag :string | ||
| # agh_contents :jsonb | ||
| # attached_shop_item_ids :bigint default([]), is an Array | ||
| # buyable_by_self :boolean default(TRUE) | ||
| # default_assigned_user_id_au :bigint | ||
| # default_assigned_user_id_ca :bigint | ||
| # default_assigned_user_id_eu :bigint | ||
| # default_assigned_user_id_in :bigint | ||
| # default_assigned_user_id_uk :bigint | ||
| # default_assigned_user_id_us :bigint | ||
| # default_assigned_user_id_xx :bigint | ||
| # description :string | ||
| # enabled :boolean | ||
| # enabled_au :boolean | ||
| # enabled_ca :boolean | ||
| # enabled_eu :boolean | ||
| # enabled_in :boolean | ||
| # enabled_uk :boolean | ||
| # enabled_us :boolean | ||
| # enabled_xx :boolean | ||
| # hacker_score :integer | ||
| # hcb_category_lock :string | ||
| # hcb_keyword_lock :string | ||
| # hcb_merchant_lock :string | ||
| # hcb_preauthorization_instructions :text | ||
| # inkthreadable_config :jsonb | ||
| # internal_description :string | ||
| # limited :boolean | ||
| # long_description :text | ||
| # max_qty :integer | ||
| # name :string | ||
| # old_prices :integer default([]), is an Array | ||
| # one_per_person_ever :boolean | ||
| # payout_percentage :integer default(0) | ||
| # price_offset_au :decimal(, ) | ||
| # price_offset_ca :decimal(, ) | ||
| # price_offset_eu :decimal(, ) | ||
| # price_offset_in :decimal(, ) | ||
| # price_offset_uk :decimal(10, 2) | ||
| # price_offset_us :decimal(, ) | ||
| # price_offset_xx :decimal(, ) | ||
| # required_ships_count :integer default(1) | ||
| # required_ships_end_date :date | ||
| # required_ships_start_date :date | ||
| # requires_ship :boolean default(FALSE) | ||
| # sale_percentage :integer | ||
| # show_in_carousel :boolean | ||
| # site_action :integer | ||
| # special :boolean | ||
| # stock :integer | ||
| # ticket_cost :decimal(, ) | ||
| # type :string | ||
| # unlock_on :date | ||
| # usd_cost :decimal(, ) | ||
| # created_at :datetime not null | ||
| # updated_at :datetime not null | ||
| # default_assigned_user_id :bigint | ||
| # user_id :bigint | ||
| # | ||
| # Indexes | ||
| # | ||
| # index_shop_items_on_default_assigned_user_id (default_assigned_user_id) | ||
| # index_shop_items_on_user_id (user_id) | ||
| # | ||
| # Foreign Keys | ||
| # | ||
| # fk_rails_... (default_assigned_user_id => users.id) ON DELETE => nullify | ||
| # fk_rails_... (user_id => users.id) | ||
| # | ||
| class ShopItem::InkthreadableItem < ShopItem | ||
| def fulfill!(order) | ||
| Shop::SendInkthreadableOrderJob.perform_later(order.id) | ||
| order.queue_for_fulfillment! | ||
NeonGamerBot-QK marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| end | ||
|
|
||
| def inkthreadable_config | ||
| super || {} | ||
| end | ||
|
|
||
| def product_number | ||
| inkthreadable_config["pn"] | ||
| end | ||
|
|
||
| def design_urls | ||
| inkthreadable_config["designs"] || {} | ||
| end | ||
|
|
||
| def shipping_method | ||
| inkthreadable_config["shipping_method"] || "regular" | ||
| end | ||
|
|
||
| def brand_name | ||
| inkthreadable_config["brand_name"] | ||
| end | ||
| end | ||
|
Comment on lines
75
to
100
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lastName fallback uses display_name.split.last which will return the entire display_name if there are no spaces. For single-word display names, this could result in the same value being used for both firstName and lastName. Consider handling this edge case more gracefully.