qube_rails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- metadata +180 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Finding the run a request names (params[:id]) and asking config.authorize_question whether the
|
|
5
|
+
# signed-in user may act on it, for the question routes and the start retry. The run's id comes
|
|
6
|
+
# from the URL, so this is what keeps one customer's users off another's runs.
|
|
7
|
+
module RunAuthorization
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# [run, operation class, record] when the run, its operation and its record are there and
|
|
11
|
+
# authorize_question says true; else nil, and a refusal is logged. Only true lets it through:
|
|
12
|
+
# a truthy record, or a forgotten return, doesn't. A refusal answers as a run that doesn't
|
|
13
|
+
# exist does (404), so a user can't tell another customer's run is there.
|
|
14
|
+
def authorized_run(action)
|
|
15
|
+
run = QubeRails::Run.find_by(id: params[:id])
|
|
16
|
+
operation = run && QubeRails.config.operation_class(run.operation)
|
|
17
|
+
record = operation && operation.record_for(run)
|
|
18
|
+
return nil unless record
|
|
19
|
+
|
|
20
|
+
unless QubeRails.config.authorize_question.call(self, record, run) == true
|
|
21
|
+
QubeRails.logger.warn("QuBe Sync: refused #{action} for run #{run.id}: config.authorize_question said no")
|
|
22
|
+
return nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[run, operation, record]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Back to the page, with message (a Symbol is an I18n key under qube_rails, a String the
|
|
29
|
+
# sentence itself) in the flash for the engine's partials to show (qube_rails/_question and
|
|
30
|
+
# _run_status). The forms post through Turbo in most apps, which
|
|
31
|
+
# shows nothing for a plain-text 409 or 422, so a failed answer or retry says why this way.
|
|
32
|
+
def back_with_error(message)
|
|
33
|
+
flash[:qube_rails_error] = message.is_a?(Symbol) ? QubeRails.t(message) : message
|
|
34
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Connects a customer's QuickBooks Desktop. QuBe Sync hosts the setup and walks them through
|
|
5
|
+
# the Web Connector, on the computer where QuickBooks is installed, and tells the connection
|
|
6
|
+
# how it went by webhook (QubeRails::ConnectionEvents, then your config.connection_events
|
|
7
|
+
# class): connection.connected, or connection.setup_cancelled.
|
|
8
|
+
#
|
|
9
|
+
# An account can have any number of connections (a company file each), so each is its own row
|
|
10
|
+
# (QubeRails::Connection), owned by the signed-in account (config.current_account). Behind
|
|
11
|
+
# your sign-in, as your ApplicationController (config.parent_controller) is.
|
|
12
|
+
class ConnectionsController < QubeRails.config.parent_controller.constantize
|
|
13
|
+
# POST /quickbooks/connections, from a "Connect QuickBooks Desktop" button, with an optional
|
|
14
|
+
# name. Best clicked on the computer where QuickBooks is installed. It sends the account's
|
|
15
|
+
# newest connection that has never connected (pending, or its setup cancelled) back through
|
|
16
|
+
# setup, so clicking Connect again doesn't pile up connections; it makes a new one when the
|
|
17
|
+
# account has none, or with new=1 (a "Connect another company file" button). A new row is
|
|
18
|
+
# written first, so the connection's webhook URL names it; if QuBe Sync doesn't answer, the
|
|
19
|
+
# row is left without a qube_id and the next click picks it up. Until connection.connected
|
|
20
|
+
# comes, show it as waiting for QuickBooks: the setup doesn't come back here.
|
|
21
|
+
#
|
|
22
|
+
# With simulated=1, outside production, it connects a simulated QuickBooks company file
|
|
23
|
+
# instead, for testing with no Windows computer: a new row whose QuBe Sync connection is
|
|
24
|
+
# connected as it's made, so connection.connected comes at once, and back to the page the
|
|
25
|
+
# button was on rather than to setup.
|
|
26
|
+
def create
|
|
27
|
+
account = signed_in_account
|
|
28
|
+
return head(:unauthorized) unless account
|
|
29
|
+
return connect_simulated(account) if simulated_requested?
|
|
30
|
+
|
|
31
|
+
connection = QubeRails::Connection.owned_by(account).never_connected.first unless params[:new].to_s == "1"
|
|
32
|
+
return send_to_setup(connection) if connection
|
|
33
|
+
|
|
34
|
+
send_to_setup(QubeRails::Connection.create!(owner: account, name: connection_name, status: "pending"),
|
|
35
|
+
fresh: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# POST /quickbooks/connections/:id/onboarding: sends one of the signed-in account's
|
|
39
|
+
# connections back through setup (a new company file, a Web Connector set up again), with its
|
|
40
|
+
# webhook URL brought up to date. Another account's connection answers 404.
|
|
41
|
+
def onboarding
|
|
42
|
+
account = signed_in_account
|
|
43
|
+
return head(:unauthorized) unless account
|
|
44
|
+
|
|
45
|
+
connection = QubeRails::Connection.owned_by(account).find_by(id: params[:id])
|
|
46
|
+
return head(:not_found) unless connection
|
|
47
|
+
|
|
48
|
+
send_to_setup(connection)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What the page says when it can't, as I18n keys under qube_rails (config/locales/en.yml).
|
|
52
|
+
RUNS_GOING = :"errors.still_syncing"
|
|
53
|
+
NO_ANSWER = :"errors.disconnect_unreachable"
|
|
54
|
+
|
|
55
|
+
# DELETE /quickbooks/connections/:id: disconnects one of the signed-in account's
|
|
56
|
+
# connections. QuBe Sync deletes its connection (the Web Connector stops syncing that
|
|
57
|
+
# company file), then the row goes, with its runs and its QuickBooks links. Refused while a
|
|
58
|
+
# run hasn't ended. Another account's connection answers 404.
|
|
59
|
+
def destroy
|
|
60
|
+
account = signed_in_account
|
|
61
|
+
return head(:unauthorized) unless account
|
|
62
|
+
|
|
63
|
+
connection = QubeRails::Connection.owned_by(account).find_by(id: params[:id])
|
|
64
|
+
return head(:not_found) unless connection
|
|
65
|
+
return back_with_error(RUNS_GOING) if work_going?(connection)
|
|
66
|
+
|
|
67
|
+
delete_on_qube(connection)
|
|
68
|
+
connection.disconnect!
|
|
69
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
70
|
+
rescue *QubeRails::Backend::NETWORK_ERRORS
|
|
71
|
+
back_with_error(NO_ANSWER)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def work_going?(connection)
|
|
77
|
+
connection.runs.where.not(state: QubeRails::Run::ENDED_STATES).exists? ||
|
|
78
|
+
connection.queued_requests.where(ended_at: nil).exists?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Already gone there (404) is gone.
|
|
82
|
+
def delete_on_qube(connection)
|
|
83
|
+
return if connection.qube_id.to_s.empty?
|
|
84
|
+
|
|
85
|
+
QubeRails.client.delete_connection(connection.qube_id)
|
|
86
|
+
rescue QubeRails::Backend::APIError => e
|
|
87
|
+
raise unless e.status == 404
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The engine's partials show the message once.
|
|
91
|
+
def back_with_error(key)
|
|
92
|
+
flash[:qube_rails_error] = QubeRails.t(key)
|
|
93
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def signed_in_account
|
|
97
|
+
account = QubeRails.config.current_account.call(self)
|
|
98
|
+
unless account
|
|
99
|
+
QubeRails.logger.warn("QuBe Sync: refused a connect: config.current_account found no signed-in account")
|
|
100
|
+
end
|
|
101
|
+
account
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def simulated_requested?
|
|
105
|
+
params[:simulated].to_s == "1"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Never in production: a customer's user can't make a connection to a fake company file.
|
|
109
|
+
def connect_simulated(account)
|
|
110
|
+
return head(:not_found) if Rails.env.production?
|
|
111
|
+
|
|
112
|
+
connection = QubeRails::Connection.create!(owner: account, name: connection_name, status: "pending")
|
|
113
|
+
made = QubeRails.client.create_connection(webhook_url: connection.webhook_url, name: connection.name,
|
|
114
|
+
type: "simulated")
|
|
115
|
+
connection.update!(qube_id: made["id"])
|
|
116
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def connection_name
|
|
120
|
+
name = params[:name].to_s.strip
|
|
121
|
+
name.empty? ? nil : name[0, 255]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# fresh: a row this request wrote, which no earlier create can have been made for.
|
|
125
|
+
def send_to_setup(connection, fresh: false)
|
|
126
|
+
# An earlier create that landed before its id was saved: that connection, not a second one.
|
|
127
|
+
connection.adopt_qube_connection if !fresh && connection.qube_id.to_s.empty?
|
|
128
|
+
if connection.qube_id
|
|
129
|
+
QubeRails.client.update_connection(connection.qube_id, webhook_url: connection.webhook_url)
|
|
130
|
+
made = QubeRails.client.regenerate_onboarding(connection.qube_id)
|
|
131
|
+
else
|
|
132
|
+
made = QubeRails.client.create_connection(webhook_url: connection.webhook_url, name: connection.name,
|
|
133
|
+
type: QubeRails.config.connection_type)
|
|
134
|
+
connection.update!(qube_id: made["id"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 303, so the browser follows the POST with a GET.
|
|
138
|
+
redirect_to made.dig("links", "onboarding"), allow_other_host: true, status: :see_other
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# Your user's answers to the questions a run asks, from the question partial
|
|
7
|
+
# (qube_rails/_question) on the record's page:
|
|
8
|
+
# POST /qube/questions/runs/<run id> a choice, with its fields
|
|
9
|
+
# GET /qube/questions/runs/<run id>/search a record field's matches, as JSON
|
|
10
|
+
# Each finds the run, its operation (config.operations) and its record, as the webhooks do, and
|
|
11
|
+
# asks config.authorize_question(controller, record, run) before anything is sent: by default,
|
|
12
|
+
# whether the signed-in account owns the run's connection. The calls to QuBe Sync are made here, with
|
|
13
|
+
# the API key, so the browser only ever talks to your app.
|
|
14
|
+
#
|
|
15
|
+
# Behind your sign-in, as your ApplicationController (config.parent_controller) is. The answers
|
|
16
|
+
# come from forms, so they carry your CSRF token, as the partial writes it.
|
|
17
|
+
class QuestionsController < QubeRails.config.parent_controller.constantize
|
|
18
|
+
# What the page says when the answer wasn't sent, as an I18n key under qube_rails.
|
|
19
|
+
CHANGED = :"errors.question_changed"
|
|
20
|
+
|
|
21
|
+
include RunAuthorization
|
|
22
|
+
|
|
23
|
+
before_action :find_question
|
|
24
|
+
|
|
25
|
+
# Posts your user's choice, with what they filled in for its fields, to the question's
|
|
26
|
+
# links.decisions, and sends them back to the page; when it can't, back with why (the
|
|
27
|
+
# partial shows it). The question is forgotten once it's
|
|
28
|
+
# answered, or once QuBe Sync says it was answered or asked again since (a 409, or a 400 such
|
|
29
|
+
# as not_awaiting_input); the run's next webhook says what came of it.
|
|
30
|
+
def create
|
|
31
|
+
choices = Array(@question && @question["choices"])
|
|
32
|
+
choice = choices.find { |c| c["id"] == params[:choice] }
|
|
33
|
+
return back_with_error(CHANGED) unless choice && @links["decisions"]
|
|
34
|
+
|
|
35
|
+
QubeRails.client.answer(@links["decisions"], choice: choice["id"], data: answer_data(choice))
|
|
36
|
+
@operation.answered(@question["token"], taken: true)
|
|
37
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
38
|
+
rescue Backend::APIError => e
|
|
39
|
+
raise unless [400, 409, 422].include?(e.status)
|
|
40
|
+
# A field QuBe Sync couldn't take: its errors.detail says which.
|
|
41
|
+
return back_with_error(refusal(e)) if e.status == 422
|
|
42
|
+
|
|
43
|
+
@operation.answered(@question["token"])
|
|
44
|
+
back_with_error(CHANGED)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The matches for a record field, for what your user typed: [{value, label, detail}], at most
|
|
48
|
+
# 20, from the question's links.search, which a run sets while it asks with one. q of 2 to 100
|
|
49
|
+
# characters and a field name of at most 100 are asked about; anything else has no matches.
|
|
50
|
+
def search
|
|
51
|
+
q = params[:q].to_s.strip
|
|
52
|
+
field = params[:field].to_s
|
|
53
|
+
return render(json: [], status: :conflict) unless @links["search"]
|
|
54
|
+
return render(json: []) unless searchable?(q, field)
|
|
55
|
+
|
|
56
|
+
row = params[:row].to_s
|
|
57
|
+
row = nil if row.empty? || row.length > 200
|
|
58
|
+
render json: QubeRails.client.search(@links["search"], field: field, q: q, row: row)
|
|
59
|
+
rescue Backend::APIError => e
|
|
60
|
+
raise unless e.status.between?(400, 499)
|
|
61
|
+
|
|
62
|
+
render json: [], status: e.status # the question has changed (409), say
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# What a search is asked about: q of 2 to 100 characters, a field name of 1 to 100.
|
|
68
|
+
# A rows field's answer: each row's filled columns, by row key. QuBe Sync drops a column the
|
|
69
|
+
# row's other answers say doesn't apply, and names any required one left out.
|
|
70
|
+
def rows_data(field, form)
|
|
71
|
+
form = form.respond_to?(:to_unsafe_h) ? form.to_unsafe_h : {}
|
|
72
|
+
field["rows"].to_a.each_with_object({}) do |row, rows|
|
|
73
|
+
given = form[row["key"]].is_a?(Hash) ? form[row["key"]] : {}
|
|
74
|
+
columns = field["columns"].to_a.select { |column| row["columns"].to_a.include?(column["name"]) }
|
|
75
|
+
rows[row["key"]] = columns.each_with_object({}) do |column, values|
|
|
76
|
+
text = given[column["name"]].to_s.strip
|
|
77
|
+
values[column["name"]] = column["type"] == "number" ? number(text) : text unless text.empty?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def searchable?(q, field)
|
|
83
|
+
q.length.between?(2, 100) && field.length.between?(1, 100)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_question
|
|
87
|
+
run, operation, record = authorized_run("an answer or search")
|
|
88
|
+
return refuse unless run
|
|
89
|
+
|
|
90
|
+
@operation = operation.new(record, run)
|
|
91
|
+
@question = run.open_question
|
|
92
|
+
@links = run.links || {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def refuse
|
|
96
|
+
if action_name == "search"
|
|
97
|
+
render json: [], status: :not_found
|
|
98
|
+
else
|
|
99
|
+
head :not_found
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The choice's own fields from the form, each as its type: a checkbox true or false, a number
|
|
104
|
+
# as a number (or as typed, for QuBe Sync's 422 to name), and a blank one left out.
|
|
105
|
+
def answer_data(choice)
|
|
106
|
+
form = params.fetch(:data, {})
|
|
107
|
+
choice["fields"].to_a.each_with_object({}) do |field, data|
|
|
108
|
+
name = field["name"]
|
|
109
|
+
next data[name] = rows_data(field, form[name]) if field["type"] == "rows"
|
|
110
|
+
|
|
111
|
+
text = form[name].to_s.strip
|
|
112
|
+
if field["type"] == "boolean"
|
|
113
|
+
data[name] = text == "true"
|
|
114
|
+
elsif !text.empty?
|
|
115
|
+
data[name] = field["type"] == "number" ? number(text) : text
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def number(text)
|
|
121
|
+
text.match?(/\A-?\d+\z/) ? text.to_i : Float(text, exception: false) || text
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What QuBe Sync said was wrong with an answer, in words (its errors.detail).
|
|
125
|
+
def refusal(error)
|
|
126
|
+
detail = JSON.parse(error.body.to_s).dig("errors", "detail")
|
|
127
|
+
detail ? QubeRails.t(:"errors.answer_refused_with", detail: detail) : QubeRails.t(:"errors.answer_refused")
|
|
128
|
+
rescue JSON::ParserError, TypeError, NoMethodError
|
|
129
|
+
QubeRails.t(:"errors.answer_refused")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# POST /qube/runs/<run id>/retry_start, from the run status partial's "Try sending again"
|
|
5
|
+
# button: sends a start QuBe Sync never confirmed (a run still "starting") again, with the same
|
|
6
|
+
# reference as its Idempotency-Key (QubeRails::Run#retry_start!), so a start that did land
|
|
7
|
+
# answers with its run rather than making another. Behind your sign-in and CSRF, as your
|
|
8
|
+
# ApplicationController (config.parent_controller) is, and config.authorize_question decides
|
|
9
|
+
# who may, as for answering the run's questions.
|
|
10
|
+
class RunsController < QubeRails.config.parent_controller.constantize
|
|
11
|
+
include RunAuthorization
|
|
12
|
+
|
|
13
|
+
# What the page says when it can't, as I18n keys under qube_rails (config/locales/en.yml).
|
|
14
|
+
NOT_STARTING = :"errors.not_starting"
|
|
15
|
+
NOT_QUIET = :"errors.not_quiet"
|
|
16
|
+
REDELIVERY_REFUSED = { 409 => :"errors.no_update_yet", 429 => :"errors.checked_just_now" }.freeze
|
|
17
|
+
NO_ANSWER = :"errors.unreachable"
|
|
18
|
+
|
|
19
|
+
def retry_start
|
|
20
|
+
run, = authorized_run("a start retry")
|
|
21
|
+
return head(:not_found) unless run
|
|
22
|
+
return back_with_error(NOT_STARTING) unless run.state == "starting"
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
run.retry_start!
|
|
26
|
+
rescue Backend::APIError, *Backend::NETWORK_ERRORS => e
|
|
27
|
+
# Refused (the run is now failed) or no answer again (still unconfirmed): the status on
|
|
28
|
+
# the page says which.
|
|
29
|
+
QubeRails.logger.warn("QuBe Sync: run #{run.id}'s start retry didn't go through (#{e.class})")
|
|
30
|
+
end
|
|
31
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# POST /qube/runs/<run id>/redeliver, from the run status partial's "Check for updates"
|
|
35
|
+
# button on a run that looks stuck (QubeRails::Run#looks_stuck?): asks QuBe Sync to
|
|
36
|
+
# send the run's latest event again (its question, or how it ended) to the run's webhook URL,
|
|
37
|
+
# for a webhook this app missed. The event comes back through the webhook route as usual;
|
|
38
|
+
# one this app already applied is skipped by its revision.
|
|
39
|
+
def redeliver
|
|
40
|
+
run, = authorized_run("a redelivery")
|
|
41
|
+
return head(:not_found) unless run
|
|
42
|
+
return back_with_error(NOT_QUIET) unless run.looks_stuck?
|
|
43
|
+
|
|
44
|
+
QubeRails.client.redeliver_run(run.connection.qube_id, run.qube_run_id)
|
|
45
|
+
redirect_back fallback_location: "/", status: :see_other
|
|
46
|
+
rescue Backend::APIError => e
|
|
47
|
+
raise unless REDELIVERY_REFUSED.key?(e.status)
|
|
48
|
+
|
|
49
|
+
back_with_error(REDELIVERY_REFUSED.fetch(e.status))
|
|
50
|
+
rescue *Backend::NETWORK_ERRORS
|
|
51
|
+
back_with_error(NO_ANSWER)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# GET qube/storyboard (and qube/storyboard/<screen>): every screen the engine's partials show,
|
|
5
|
+
# from sample data (QubeRails::Storyboard), in your app's layout, so your CSS and
|
|
6
|
+
# config.ui_classes apply and each piece's override path is beside it. Only when
|
|
7
|
+
# config.storyboard is true (by default, in development), behind your sign-in as the other
|
|
8
|
+
# routes are. Its searches answer from the samples and its forms post to a no-op: nothing
|
|
9
|
+
# reaches QuBe Sync or the database.
|
|
10
|
+
class StoryboardController < QubeRails.config.parent_controller.constantize
|
|
11
|
+
NOTHING_SENT = "Storyboard: nothing was sent."
|
|
12
|
+
|
|
13
|
+
# Your layout's own route helpers (root_path) resolve as on your pages; the engine's are
|
|
14
|
+
# qube_rails.*.
|
|
15
|
+
include Rails.application.routes.url_helpers
|
|
16
|
+
helper Rails.application.routes.url_helpers
|
|
17
|
+
|
|
18
|
+
prepend_before_action :require_storyboard
|
|
19
|
+
before_action :find_screen, except: :index
|
|
20
|
+
|
|
21
|
+
def index
|
|
22
|
+
@screens = Storyboard.screens
|
|
23
|
+
render :show
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show
|
|
27
|
+
@screens = [@screen]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def search
|
|
31
|
+
render json: Storyboard.matches(@screen, params[:field], params[:q])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def answer
|
|
35
|
+
flash[:qube_rails_storyboard] = NOTHING_SENT
|
|
36
|
+
redirect_to qube_rails.storyboard_screen_path(@screen.key), status: :see_other
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def require_storyboard
|
|
42
|
+
head :not_found unless QubeRails.config.storyboard == true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_screen
|
|
46
|
+
@screen = Storyboard.find(params[:screen])
|
|
47
|
+
head :not_found unless @screen
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Receives QuBe Sync's webhooks at POST /webhooks/qube/runs/<run id>, the URL each run was
|
|
5
|
+
# started with (QubeRails::Run#webhook_url), and at /webhooks/qube/connections/<connection id>,
|
|
6
|
+
# each connection's (QubeRails::Connection#webhook_url), and at /webhooks/qube/requests/<id>,
|
|
7
|
+
# each plain request's (QubeRails::QueuedRequest#webhook_url). It only verifies the delivery, checks
|
|
8
|
+
# the row is there and enqueues it (config.enqueue), so it answers well inside the 5 seconds
|
|
9
|
+
# QuBe Sync waits: the job does the rest.
|
|
10
|
+
#
|
|
11
|
+
# The verification is the qube_sync gem's: the signature over the raw body, with any v1
|
|
12
|
+
# matching in constant time, and a timestamp within 5 minutes either way. No sign-in and no
|
|
13
|
+
# CSRF token: the signature is what proves QuBe Sync sent it.
|
|
14
|
+
class WebhooksController < ActionController::API
|
|
15
|
+
# The largest body taken: a pull_changes page can be a few megabytes. Anything larger is
|
|
16
|
+
# answered 413 before it's read.
|
|
17
|
+
MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
# Any content type: the body is only ever read raw, so Rails never parses it as params.
|
|
20
|
+
wrap_parameters false
|
|
21
|
+
|
|
22
|
+
def run_event
|
|
23
|
+
receive("run", QubeRails::Run)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connection_event
|
|
27
|
+
receive("connection", QubeRails::Connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def request_event
|
|
31
|
+
receive("request", QubeRails::QueuedRequest)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def receive(kind, model)
|
|
37
|
+
return too_large if request.content_length.to_i > MAX_BODY_BYTES
|
|
38
|
+
|
|
39
|
+
body = request.raw_post.to_s
|
|
40
|
+
return too_large if body.bytesize > MAX_BODY_BYTES
|
|
41
|
+
|
|
42
|
+
Backend.verify(body, request.headers["X-Qube-Signature"], secrets: QubeRails.config.webhook_secret)
|
|
43
|
+
id = request.path_parameters[:id].to_s
|
|
44
|
+
# A row that isn't there (deleted, or a wrong webhook_url): QuBe Sync retries it, and shows
|
|
45
|
+
# it as a failed delivery.
|
|
46
|
+
return head(:not_found) unless model.where(id: id).exists?
|
|
47
|
+
|
|
48
|
+
# The signature covers the body, not the URL, so the id is only a pointer: the job checks
|
|
49
|
+
# the event's reference and connection id against the row.
|
|
50
|
+
# The body goes as text, which any queue can carry; the job parses it again. A 200 tells
|
|
51
|
+
# QuBe Sync it's taken and it won't send it again, so a queue that didn't take it answers
|
|
52
|
+
# 503 and QuBe Sync retries.
|
|
53
|
+
return head(:ok) if enqueued?(kind, id, body.dup.force_encoding(Encoding::UTF_8))
|
|
54
|
+
|
|
55
|
+
head :service_unavailable
|
|
56
|
+
rescue Backend::SignatureError => e
|
|
57
|
+
# Its reason (missing, malformed, stale or mismatch) is safe to log: never the secret, the
|
|
58
|
+
# signature or the body.
|
|
59
|
+
QubeRails.logger.warn("QuBe Sync: refused a webhook: #{e.reason} (#{request.path}): " \
|
|
60
|
+
"#{SIGNATURE_HINTS[e.reason]}")
|
|
61
|
+
head :unauthorized
|
|
62
|
+
rescue Backend::ParseError => e
|
|
63
|
+
# Signed, but not a JSON object: nothing a job could take.
|
|
64
|
+
QubeRails.logger.warn("QuBe Sync: refused a webhook: #{e.message}")
|
|
65
|
+
head :bad_request
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# false (ActiveJob 7.1+ when the adapter couldn't enqueue, or a callback stopped it) or an
|
|
69
|
+
# error from the enqueue is a delivery not taken. Logged without the body.
|
|
70
|
+
def enqueued?(kind, id, body)
|
|
71
|
+
result = QubeRails.config.enqueue.call(kind: kind, id: id, body: body)
|
|
72
|
+
return true unless result == false
|
|
73
|
+
|
|
74
|
+
QubeRails.logger.error("QuBe Sync: couldn't enqueue a webhook for #{kind} #{id}: the enqueue returned false; " \
|
|
75
|
+
"answered 503 so QuBe Sync sends it again")
|
|
76
|
+
false
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
QubeRails.logger.error("QuBe Sync: couldn't enqueue a webhook for #{kind} #{id} (#{e.class}); " \
|
|
79
|
+
"answered 503 so QuBe Sync sends it again")
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# What to check for each reason a signature is refused, for the developer reading the log.
|
|
84
|
+
SIGNATURE_HINTS = {
|
|
85
|
+
mismatch: "it isn't signed with QUBE_WEBHOOK_SECRET (config.webhook_secret). Set that to this " \
|
|
86
|
+
"app's webhook secret, which `qube env --write .env` writes, and restart; QuBe Sync " \
|
|
87
|
+
"sends it again",
|
|
88
|
+
stale: "its signature's time is over 5 minutes from this server's clock. Check the clock",
|
|
89
|
+
missing: "it has no X-Qube-Signature header, so it isn't from QuBe Sync",
|
|
90
|
+
malformed: "its X-Qube-Signature header isn't t=<seconds>,v1=<hex>, so it isn't from QuBe Sync"
|
|
91
|
+
}.freeze
|
|
92
|
+
private_constant :SIGNATURE_HINTS
|
|
93
|
+
|
|
94
|
+
def too_large
|
|
95
|
+
QubeRails.logger.warn("QuBe Sync: refused a webhook: its body is over #{MAX_BODY_BYTES} bytes")
|
|
96
|
+
head 413
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Deletes ended runs older than config.prune_runs_after (Run.prunable), with their
|
|
5
|
+
# notifications, and ended plain requests (QueuedRequest.prunable) with their deliveries. Links
|
|
6
|
+
# and sync marks stay. Does nothing until the setting is written.
|
|
7
|
+
class PruneRunsJob < ActiveJob::Base
|
|
8
|
+
queue_as { QubeRails.config.queue }
|
|
9
|
+
|
|
10
|
+
def perform
|
|
11
|
+
after = QubeRails.config.prune_runs_after
|
|
12
|
+
return unless after
|
|
13
|
+
|
|
14
|
+
QubeRails::Run.prunable(older_than: after).in_batches(of: 500).delete_all
|
|
15
|
+
QubeRails::QueuedRequest.prunable(older_than: after).in_batches(of: 500).delete_all
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Tells a record's pages to refresh once more, a few seconds after an event changed its run
|
|
5
|
+
# (QubeRails.broadcast_refresh). A page still loading when the first refresh went out (the
|
|
6
|
+
# redirect after "Send to QuickBooks", when the simulator asks its question at once) had no
|
|
7
|
+
# subscription yet; by now it has, so a question that landed meanwhile still shows.
|
|
8
|
+
class RefreshJob < ActiveJob::Base
|
|
9
|
+
queue_as { QubeRails.config.queue }
|
|
10
|
+
|
|
11
|
+
# The record was deleted since: there's no page to refresh.
|
|
12
|
+
discard_on ActiveJob::DeserializationError
|
|
13
|
+
|
|
14
|
+
def perform(record)
|
|
15
|
+
QubeRails.broadcast_refresh_now(record)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Runs each verified QuBe Sync webhook on your ActiveJob queue (Sidekiq, Solid Queue,
|
|
5
|
+
# delayed_job, whichever your app uses), on config.queue: a run's event goes to its operation,
|
|
6
|
+
# with its record (QubeRails::Operation#handle), and a connection's to your connection_events
|
|
7
|
+
# class (QubeRails.run). It's config.enqueue's
|
|
8
|
+
# default; an app with another queue sets enqueue and calls QubeRails.run from its worker.
|
|
9
|
+
#
|
|
10
|
+
# QuBe Sync had its 2xx when the event was enqueued, so it won't send it again: a job that
|
|
11
|
+
# raises is retried here, config.job_attempts times in all (10 unless set) with a growing wait,
|
|
12
|
+
# since Solid Queue, GoodJob and :async don't retry a job that raises. When the attempts run
|
|
13
|
+
# out it logs an error and raises, so your adapter's own handling (Sidekiq's retries and dead
|
|
14
|
+
# set) sees it.
|
|
15
|
+
#
|
|
16
|
+
# With more than one worker, a run's events can run in any order, a later one first. handle
|
|
17
|
+
# applies a run event only when its revision is newer than the last applied, under the run row's
|
|
18
|
+
# lock, and takes a notification once, by its delivery_id.
|
|
19
|
+
#
|
|
20
|
+
# A workflow that sends notifications (pull_changes's pages) waits for each one's 2xx before it
|
|
21
|
+
# sends the next. The endpoint answers at once, so they come as fast as it answers, not after
|
|
22
|
+
# each is saved: see each operation's notification handler.
|
|
23
|
+
class WebhookJob < ActiveJob::Base
|
|
24
|
+
queue_as { QubeRails.config.queue }
|
|
25
|
+
|
|
26
|
+
# :polynomially_longer since Rails 7.1, which deprecates :exponentially_longer (the same wait).
|
|
27
|
+
BACKOFF = ActiveJob.gem_version >= Gem::Version.new("7.1") ? :polynomially_longer : :exponentially_longer
|
|
28
|
+
|
|
29
|
+
ATTEMPTS = QubeRails.config.job_attempts
|
|
30
|
+
|
|
31
|
+
retry_on StandardError, wait: BACKOFF, attempts: ATTEMPTS do |job, error|
|
|
32
|
+
kind, id = job.arguments
|
|
33
|
+
QubeRails.logger.error("QuBe Sync: gave up on a webhook for #{kind} #{id} after #{ATTEMPTS} " \
|
|
34
|
+
"attempts: #{error.class}: #{error.message}")
|
|
35
|
+
raise error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# kind is "run", "connection" or "request", id the row's.
|
|
39
|
+
def perform(kind, id, body)
|
|
40
|
+
QubeRails.run(kind, id, body)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# The base of the engine's models. Their tables are qube_connections, qube_runs,
|
|
5
|
+
# qube_notifications, qube_quickbooks_records, qube_sync_marks, qube_requests and
|
|
6
|
+
# qube_request_deliveries, from the engine's migrations
|
|
7
|
+
# (`bin/rails qube_rails:install:migrations`).
|
|
8
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
|
|
11
|
+
# The type and id a polymorphic column keeps for record: its base class's name (as Rails
|
|
12
|
+
# writes it) and its id as a string, so an app's integer and UUID keys both fit.
|
|
13
|
+
def self.key_for(record)
|
|
14
|
+
[record.class.base_class.name, record.id.to_s]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|