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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +161 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +967 -0
  5. data/app/assets/javascripts/qube_rails/question.js +457 -0
  6. data/app/assets/stylesheets/qube_rails.css +1115 -0
  7. data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
  8. data/app/controllers/qube_rails/connections_controller.rb +141 -0
  9. data/app/controllers/qube_rails/questions_controller.rb +132 -0
  10. data/app/controllers/qube_rails/runs_controller.rb +54 -0
  11. data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
  12. data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
  13. data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
  14. data/app/jobs/qube_rails/refresh_job.rb +18 -0
  15. data/app/jobs/qube_rails/webhook_job.rb +43 -0
  16. data/app/models/qube_rails/application_record.rb +17 -0
  17. data/app/models/qube_rails/connection.rb +303 -0
  18. data/app/models/qube_rails/notification.rb +12 -0
  19. data/app/models/qube_rails/queued_request.rb +66 -0
  20. data/app/models/qube_rails/quickbooks_record.rb +36 -0
  21. data/app/models/qube_rails/request_delivery.rb +12 -0
  22. data/app/models/qube_rails/run.rb +187 -0
  23. data/app/models/qube_rails/sync_mark.rb +21 -0
  24. data/app/views/qube_rails/_error.html.erb +13 -0
  25. data/app/views/qube_rails/_question.html.erb +21 -0
  26. data/app/views/qube_rails/_run_status.html.erb +22 -0
  27. data/app/views/qube_rails/question/_choice.html.erb +20 -0
  28. data/app/views/qube_rails/question/_details.html.erb +6 -0
  29. data/app/views/qube_rails/question/_field.html.erb +15 -0
  30. data/app/views/qube_rails/question/_footer.html.erb +7 -0
  31. data/app/views/qube_rails/question/_message.html.erb +2 -0
  32. data/app/views/qube_rails/question/_pick.html.erb +19 -0
  33. data/app/views/qube_rails/question/_picks.html.erb +6 -0
  34. data/app/views/qube_rails/question/_question.html.erb +24 -0
  35. data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
  36. data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
  37. data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
  38. data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
  39. data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
  40. data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
  41. data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
  42. data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
  43. data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
  44. data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
  45. data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
  46. data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
  47. data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
  48. data/app/views/qube_rails/run_status/_status.html.erb +21 -0
  49. data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
  50. data/app/views/qube_rails/storyboard/show.html.erb +86 -0
  51. data/config/locales/en.yml +46 -0
  52. data/config/routes.rb +35 -0
  53. data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
  54. data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
  55. data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
  56. data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
  57. data/lib/generators/qube_rails/install_generator.rb +47 -0
  58. data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
  59. data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
  60. data/lib/generators/qube_rails/workflows_generator.rb +77 -0
  61. data/lib/qube_rails/backend.rb +70 -0
  62. data/lib/qube_rails/cache_dedupe.rb +34 -0
  63. data/lib/qube_rails/configuration.rb +319 -0
  64. data/lib/qube_rails/connection_events.rb +167 -0
  65. data/lib/qube_rails/database_dedupe.rb +28 -0
  66. data/lib/qube_rails/engine.rb +33 -0
  67. data/lib/qube_rails/json_coder.rb +43 -0
  68. data/lib/qube_rails/operation.rb +464 -0
  69. data/lib/qube_rails/question_view.rb +219 -0
  70. data/lib/qube_rails/request.rb +289 -0
  71. data/lib/qube_rails/rows_view.rb +146 -0
  72. data/lib/qube_rails/run_status_view.rb +71 -0
  73. data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
  74. data/lib/qube_rails/storyboard/choose.json +51 -0
  75. data/lib/qube_rails/storyboard/enum_select.json +56 -0
  76. data/lib/qube_rails/storyboard/missing_record.json +141 -0
  77. data/lib/qube_rails/storyboard/name_clash.json +59 -0
  78. data/lib/qube_rails/storyboard/near_match.json +77 -0
  79. data/lib/qube_rails/storyboard/number_boolean.json +54 -0
  80. data/lib/qube_rails/storyboard/record_search.json +90 -0
  81. data/lib/qube_rails/storyboard/refusal.json +53 -0
  82. data/lib/qube_rails/storyboard/rows.json +359 -0
  83. data/lib/qube_rails/storyboard.rb +165 -0
  84. data/lib/qube_rails/sync_status.rb +124 -0
  85. data/lib/qube_rails/version.rb +5 -0
  86. data/lib/qube_rails/view_helpers.rb +29 -0
  87. data/lib/qube_rails.rb +201 -0
  88. 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