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,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# A setting the app hasn't written yet, raised the first time it's needed.
|
|
5
|
+
class NotConfigured < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The only place qube_rails reads your app from: QubeRails.configure in
|
|
8
|
+
# config/initializers/qube_rails.rb (`rails g qube_rails:install` writes one). A setting your
|
|
9
|
+
# app hasn't written raises QubeRails::NotConfigured, naming that file, the first time it's
|
|
10
|
+
# needed, so nothing silently passes.
|
|
11
|
+
class Configuration
|
|
12
|
+
FILE = "config/initializers/qube_rails.rb"
|
|
13
|
+
DEFAULT_BASE_URL = "https://qubesync.com"
|
|
14
|
+
|
|
15
|
+
attr_writer :base_url, :api_key, :webhook_secret, :webhook_url, :queue, :parent_controller
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@operations = nil
|
|
19
|
+
@connection_events = nil
|
|
20
|
+
@current_account = nil
|
|
21
|
+
@authorize_question = nil
|
|
22
|
+
@enqueue = nil
|
|
23
|
+
@dedupe = nil
|
|
24
|
+
@queue = nil
|
|
25
|
+
@job_attempts = nil
|
|
26
|
+
@prune_runs_after = nil
|
|
27
|
+
@connection_type = nil
|
|
28
|
+
@parent_controller = nil
|
|
29
|
+
@api_key = nil
|
|
30
|
+
@base_url = nil
|
|
31
|
+
@webhook_secret = nil
|
|
32
|
+
@webhook_url = nil
|
|
33
|
+
@ui_classes = {}.freeze
|
|
34
|
+
@storyboard = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The pieces of the engine's screens config.ui_classes can add classes to.
|
|
38
|
+
UI_PIECES = %i[
|
|
39
|
+
question message details choice choice_detail choice_description field label input select checkbox
|
|
40
|
+
button button_secondary search search_results search_result status summary detail error
|
|
41
|
+
rows row row_header segmented segment row_fields use_for_all stall
|
|
42
|
+
picks pick pick_detail pick_action reason button_ghost choice_footer choice_divider
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Your CSS framework's classes for the engine's screens, by piece, added beside each element's
|
|
46
|
+
# qube- classes (a String, or an Array of them):
|
|
47
|
+
# config.ui_classes = { button: "btn btn-primary", input: "form-control", select: "form-select" }
|
|
48
|
+
# A piece not in UI_PIECES raises, naming them.
|
|
49
|
+
def ui_classes=(map)
|
|
50
|
+
raise ArgumentError, "ui_classes is a Hash of piece => CSS classes, in #{FILE}" unless map.respond_to?(:each_pair)
|
|
51
|
+
|
|
52
|
+
list = {}
|
|
53
|
+
map.each_pair do |piece, classes|
|
|
54
|
+
key = piece.to_s.to_sym
|
|
55
|
+
unless UI_PIECES.include?(key)
|
|
56
|
+
raise ArgumentError, "ui_classes has no piece #{piece.inspect}: it takes #{UI_PIECES.join(", ")}, in #{FILE}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
list[key] = Array(classes).flatten.map(&:to_s).reject(&:empty?).join(" ").freeze
|
|
60
|
+
end
|
|
61
|
+
@ui_classes = list.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
attr_reader :ui_classes
|
|
65
|
+
|
|
66
|
+
# Whether GET qube/storyboard shows every screen with sample data. Default: in development.
|
|
67
|
+
def storyboard
|
|
68
|
+
return @storyboard unless @storyboard.nil?
|
|
69
|
+
|
|
70
|
+
defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.development? : false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def storyboard=(value)
|
|
74
|
+
raise ArgumentError, "storyboard is true or false, in #{FILE}" unless [true, false].include?(value)
|
|
75
|
+
|
|
76
|
+
@storyboard = value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Each operation, and each plain request (QubeRails::Request), by its slug (the SLUG its row
|
|
80
|
+
# keeps, in qube_runs.operation or qube_requests.operation), as class *names*:
|
|
81
|
+
# config.operations = { "create_invoice" => "QuickbooksDesktop::CreateInvoice" }
|
|
82
|
+
# Names, not classes, so code reloading in development finds the class again each time. A
|
|
83
|
+
# fixed list, so a class is never looked up from anything a request says.
|
|
84
|
+
def operations=(map)
|
|
85
|
+
unless map.respond_to?(:each_pair)
|
|
86
|
+
raise ArgumentError, "operations is a Hash of slug => class name, in #{FILE}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
list = {}
|
|
90
|
+
map.each_pair do |slug, name|
|
|
91
|
+
list[slug.to_s] = name.is_a?(Module) ? name.name : name.to_s
|
|
92
|
+
end
|
|
93
|
+
@operations = list.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def operations
|
|
97
|
+
@operations || not_configured(:operations, "the operations by slug, as class names")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Your class for a connection's own events (a QubeRails::ConnectionEvents), by name:
|
|
101
|
+
# config.connection_events = "QuickbooksDesktop::ConnectionEvents"
|
|
102
|
+
def connection_events=(name)
|
|
103
|
+
@connection_events = name.is_a?(Module) ? name.name : name && name.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def connection_events
|
|
107
|
+
@connection_events || not_configured(:connection_events, "your ConnectionEvents class's name")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def connection_events_class
|
|
111
|
+
constantize(connection_events)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The operation class for a slug, or nil when none of your operations has it.
|
|
115
|
+
def operation_class(slug)
|
|
116
|
+
name = operations[slug.to_s]
|
|
117
|
+
name && constantize(name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The signed-in customer's account, called with the controller handling the request (so your
|
|
121
|
+
# current_user and other helpers are there):
|
|
122
|
+
# config.current_account = ->(controller) { controller.current_user.account }
|
|
123
|
+
# It owns the connections onboarding makes (QubeRails::Connection#owner), and only its own can
|
|
124
|
+
# be sent back through setup; nil answers 401.
|
|
125
|
+
def current_account=(callable)
|
|
126
|
+
@current_account = callable!(:current_account, callable)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def current_account
|
|
130
|
+
@current_account || not_configured(:current_account,
|
|
131
|
+
"->(controller) { the signed-in customer's account }")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Whether the signed-in user may answer or search the question a run asks about record. The
|
|
135
|
+
# run (a QubeRails::Run) carries its operation and its connection, whose owner is the account
|
|
136
|
+
# that connected it. Only true allows; anything else is refused (404, as for a missing run)
|
|
137
|
+
# and logged. The run's id comes from the URL, so this is what keeps one customer's users off
|
|
138
|
+
# another's runs.
|
|
139
|
+
#
|
|
140
|
+
# The default allows the signed-in account (current_account) when it owns the run's
|
|
141
|
+
# connection. Set your own to check more (that the user may see the record), or less (staff
|
|
142
|
+
# who act across accounts):
|
|
143
|
+
# config.authorize_question = ->(controller, record, run) {
|
|
144
|
+
# run.connection.owner == controller.current_user.account && controller.current_user.can_edit?(record)
|
|
145
|
+
# }
|
|
146
|
+
def authorize_question=(callable)
|
|
147
|
+
@authorize_question = callable!(:authorize_question, callable)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def authorize_question
|
|
151
|
+
@authorize_question || method(:owner_answers)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Hands a verified delivery, { kind:, id:, body: } (kind "run", "connection" or "request", id the row's),
|
|
155
|
+
# to a queue. The default puts it on ActiveJob (QubeRails::WebhookJob, on the queue setting).
|
|
156
|
+
# Your own queue's worker calls QubeRails.run(kind, id, body).
|
|
157
|
+
def enqueue=(callable)
|
|
158
|
+
@enqueue = callable!(:enqueue, callable)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def enqueue
|
|
162
|
+
@enqueue || method(:enqueue_on_active_job)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# How many times QubeRails::WebhookJob runs a delivery before it gives up (retry_on, with a
|
|
166
|
+
# growing wait), read when the job class loads. Default 10.
|
|
167
|
+
def job_attempts
|
|
168
|
+
@job_attempts || 10
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def job_attempts=(attempts)
|
|
172
|
+
unless attempts.is_a?(Integer) && attempts >= 1
|
|
173
|
+
raise ArgumentError, "job_attempts is a whole number of 1 or more, in #{FILE}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
@job_attempts = attempts
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The ActiveJob queue QubeRails::WebhookJob runs on.
|
|
180
|
+
def queue
|
|
181
|
+
@queue || :default
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The type onboarding creates connections as: "realtime" (the default; workflows need it) or
|
|
185
|
+
# "standard".
|
|
186
|
+
def connection_type
|
|
187
|
+
@connection_type || "realtime"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def connection_type=(type)
|
|
191
|
+
unless %w[realtime standard].include?(type.to_s)
|
|
192
|
+
raise ArgumentError, "connection_type is 'realtime' or 'standard', in #{FILE}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@connection_type = type.to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# How long ended runs are kept before QubeRails::PruneRunsJob deletes them (nil: never).
|
|
199
|
+
attr_reader :prune_runs_after
|
|
200
|
+
|
|
201
|
+
def prune_runs_after=(duration)
|
|
202
|
+
unless duration.nil? || (duration.respond_to?(:to_i) && duration.to_i >= 1.day.to_i)
|
|
203
|
+
raise ArgumentError, "prune_runs_after is nil or a duration of a day or more, in #{FILE}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
@prune_runs_after = duration
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Where the notification delivery ids each run has taken are kept, so a redelivery runs its
|
|
210
|
+
# handler once. The default is the engine's qube_notifications table
|
|
211
|
+
# (QubeRails::DatabaseDedupe), written in the same transaction as the handler. Another store
|
|
212
|
+
# answers once(run, delivery_id) { } (running the block unless it's taken), or seen?(key) and
|
|
213
|
+
# remember(key), as QubeRails::CacheDedupe (Rails.cache, not transactional) does.
|
|
214
|
+
def dedupe=(store)
|
|
215
|
+
unless dedupe_store?(store)
|
|
216
|
+
raise ArgumentError, "dedupe needs once(run, delivery_id) { }, or seen?(key) and remember(key), in #{FILE}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@dedupe = store
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def dedupe
|
|
223
|
+
@dedupe ||= DatabaseDedupe.new
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The controller onboarding and the question routes inherit, by name, so your sign-in and
|
|
227
|
+
# CSRF protection apply to them.
|
|
228
|
+
def parent_controller
|
|
229
|
+
@parent_controller || "ApplicationController"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# QUBE_API_KEY, which `qube env --write .env` writes.
|
|
233
|
+
def api_key
|
|
234
|
+
@api_key || env("QUBE_API_KEY") || env_not_set(:api_key, "QUBE_API_KEY")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# QUBE_URL, or https://qubesync.com.
|
|
238
|
+
def base_url
|
|
239
|
+
@base_url || env("QUBE_URL") || DEFAULT_BASE_URL
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# QUBE_WEBHOOK_SECRET, or a list of secrets while one rotates.
|
|
243
|
+
def webhook_secret
|
|
244
|
+
@webhook_secret || env("QUBE_WEBHOOK_SECRET") ||
|
|
245
|
+
env_not_set(:webhook_secret, "QUBE_WEBHOOK_SECRET")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# QUBE_WEBHOOK_URL, the base of each run's and each connection's webhook URL: `qube listen
|
|
249
|
+
# --write-env .env` writes it in development, and in production it's your app's
|
|
250
|
+
# /webhooks/qube.
|
|
251
|
+
def webhook_url
|
|
252
|
+
url = @webhook_url || env("QUBE_WEBHOOK_URL") ||
|
|
253
|
+
env_not_set(:webhook_url, "QUBE_WEBHOOK_URL")
|
|
254
|
+
url.to_s.chomp("/")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Never the API key or a webhook secret.
|
|
258
|
+
def inspect
|
|
259
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} operations=#{@operations.inspect} " \
|
|
260
|
+
"connection_events=#{@connection_events.inspect} api_key=#{secret_state(@api_key, "QUBE_API_KEY")} " \
|
|
261
|
+
"webhook_secret=#{secret_state(@webhook_secret, "QUBE_WEBHOOK_SECRET")}>"
|
|
262
|
+
end
|
|
263
|
+
alias to_s inspect
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# Answers once(run, delivery_id) { }, or seen?(key) and remember(key).
|
|
268
|
+
def dedupe_store?(store)
|
|
269
|
+
store.respond_to?(:once) || (store.respond_to?(:seen?) && store.respond_to?(:remember))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The default authorize_question: the signed-in account owns the run's connection. Compared
|
|
273
|
+
# by type and id, as the connection keeps them, so a nil or unsaved account never matches.
|
|
274
|
+
def owner_answers(controller, _record, run)
|
|
275
|
+
account = current_account.call(controller)
|
|
276
|
+
return false if account.nil? || account.id.nil?
|
|
277
|
+
return false if account.respond_to?(:persisted?) && !account.persisted?
|
|
278
|
+
|
|
279
|
+
klass = account.class
|
|
280
|
+
type = klass.respond_to?(:base_class) ? klass.base_class.name : klass.name
|
|
281
|
+
connection = run.connection
|
|
282
|
+
type == connection.owner_type && account.id.to_s == connection.owner_id.to_s
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def enqueue_on_active_job(delivery)
|
|
286
|
+
QubeRails::WebhookJob.perform_later(delivery[:kind], delivery[:id], delivery[:body])
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def constantize(name)
|
|
290
|
+
name.to_s.constantize
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def env(name)
|
|
294
|
+
value = ENV[name]
|
|
295
|
+
value unless value.nil? || value.strip.empty?
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def callable!(setting, callable)
|
|
299
|
+
return nil if callable.nil?
|
|
300
|
+
return callable if callable.respond_to?(:call)
|
|
301
|
+
|
|
302
|
+
raise ArgumentError, "#{setting} must respond to call, in #{FILE}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def secret_state(value, env_name)
|
|
306
|
+
value || env(env_name) ? "[hidden]" : "nil"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def not_configured(setting, what)
|
|
310
|
+
raise NotConfigured, "QuBe Sync: #{setting} isn't set yet. Set config.#{setting} (#{what}) " \
|
|
311
|
+
"in QubeRails.configure, in #{FILE}."
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def env_not_set(setting, env_name)
|
|
315
|
+
raise NotConfigured, "QuBe Sync: #{setting} isn't set yet. Set #{env_name} in the " \
|
|
316
|
+
"environment, or config.#{setting} in QubeRails.configure, in #{FILE}."
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# A connection's own events, which QuBe Sync POSTs to the connection's webhook URL,
|
|
5
|
+
# QUBE_WEBHOOK_URL/connections/<connection id>, set when onboarding makes it. The webhook job
|
|
6
|
+
# applies each to the connection row (status, stall_reason, revision) and then hands it to
|
|
7
|
+
# your class (a subclass of this, named by config.connection_events), which sees the
|
|
8
|
+
# connection and its owner:
|
|
9
|
+
# connection.connected the Web Connector checked in: QuickBooks is connected
|
|
10
|
+
# connection.setup_cancelled they chose "Cancel setup" on the setup page
|
|
11
|
+
# connection.stalled requests are stuck; see stall_reason
|
|
12
|
+
# connection.resumed after a stall, they're being taken again
|
|
13
|
+
#
|
|
14
|
+
# The setup ends on the QuickBooks computer, which usually isn't signed in to your app, so
|
|
15
|
+
# nothing comes back to it there: connection.connected, signed by QuBe Sync, is how your app
|
|
16
|
+
# knows. connection.status already says so when your handler runs.
|
|
17
|
+
class ConnectionEvents
|
|
18
|
+
# The status each event leaves the connection in.
|
|
19
|
+
STATUS = {
|
|
20
|
+
"connection.connected" => "connected",
|
|
21
|
+
"connection.setup_cancelled" => "setup_cancelled",
|
|
22
|
+
"connection.stalled" => "stalled",
|
|
23
|
+
"connection.resumed" => "connected"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :connection
|
|
27
|
+
|
|
28
|
+
def initialize(connection)
|
|
29
|
+
@connection = connection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The account (or whatever your app's customer is) that connected it.
|
|
33
|
+
def owner
|
|
34
|
+
connection.owner
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One of the connection's webhooks, from the webhook job, as the qube_sync gem parsed it (a
|
|
38
|
+
# ConnectionEvent). Deliveries come at least once and, from a queue with more than one
|
|
39
|
+
# worker, in any order:
|
|
40
|
+
# - an event whose connection isn't this row's qube_id (a signed body posted to another
|
|
41
|
+
# connection's URL) is logged and skipped;
|
|
42
|
+
# - any other is applied only when its revision is newer than the last one applied, so a
|
|
43
|
+
# retried connection.stalled that comes after its connection.resumed is skipped.
|
|
44
|
+
# It holds the connection row's lock while it does, so two workers never apply its events at
|
|
45
|
+
# once, and what your handler writes is saved with the revision or not at all.
|
|
46
|
+
#
|
|
47
|
+
# A row with no qube_id yet can't be checked: its id is adopted from QuBe Sync when one of the
|
|
48
|
+
# app's connections there has this row's webhook URL (a create that landed but whose id
|
|
49
|
+
# wasn't saved yet), and otherwise QubeRails::NotReady is raised, so the queue retries it.
|
|
50
|
+
def handle(event)
|
|
51
|
+
if connection.qube_id.to_s.empty?
|
|
52
|
+
connection.adopt_qube_connection
|
|
53
|
+
if connection.qube_id.to_s.empty?
|
|
54
|
+
raise NotReady, "QuBe Sync: connection #{connection.id} has no qube_id yet; #{event.event} waits for it"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
applied = connection.with_lock { apply(event) }
|
|
59
|
+
refresh_pages if applied && (event.stalled? || event.resumed?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# What your app does with each: until you fill them in, each logs.
|
|
63
|
+
|
|
64
|
+
# QuickBooks is connected: show the connection as connected, and start syncing.
|
|
65
|
+
def connected(event)
|
|
66
|
+
log_event(event)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# They cancelled the setup: offer Connect QuickBooks Desktop again.
|
|
70
|
+
def setup_cancelled(event)
|
|
71
|
+
log_event(event)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Your requests are waiting and QuickBooks hasn't been reached. Show the customer, and tell
|
|
75
|
+
# them what the event's stall_reason (and connection.stall_reason) says:
|
|
76
|
+
# - "not_checking_in": the Web Connector isn't calling in. Turn on the QuickBooks computer,
|
|
77
|
+
# and check the Web Connector runs.
|
|
78
|
+
# - "quickbooks_unavailable": it is calling in and can't reach QuickBooks. Open QuickBooks
|
|
79
|
+
# with the company file.
|
|
80
|
+
# A stall can send this again with a new reason (the computer was turned on, and QuickBooks
|
|
81
|
+
# isn't open yet): show the newest. resumed clears it.
|
|
82
|
+
def stalled(event)
|
|
83
|
+
log_event(event)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Clear what stalled showed, whichever its reason.
|
|
87
|
+
def resumed(event)
|
|
88
|
+
log_event(event)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The account disconnected it (DELETE quickbooks/connections/:id): delete your rows that
|
|
92
|
+
# point at it. Runs in the transaction that deletes it, before its runs and links go.
|
|
93
|
+
def disconnected; end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Only a connection event carries a connection: any other event posted to this URL is
|
|
98
|
+
# skipped as not its connection.
|
|
99
|
+
def apply(event)
|
|
100
|
+
connection_id = event.type == "connection" ? event.connection_id : nil
|
|
101
|
+
unless this_connection?(connection_id)
|
|
102
|
+
QubeRails.logger.warn("QuBe Sync: connection #{connection.id} skipped #{event.event} for " \
|
|
103
|
+
"#{connection_id.inspect}: not this connection")
|
|
104
|
+
return false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Applied already, or older than what was.
|
|
108
|
+
return false unless event.newer_than?(connection.revision)
|
|
109
|
+
|
|
110
|
+
status = STATUS[event.event]
|
|
111
|
+
if status
|
|
112
|
+
connection.status = status
|
|
113
|
+
connection.stall_reason = event.stalled? ? event.stall_reason : nil
|
|
114
|
+
end
|
|
115
|
+
connection.revision = event.revision
|
|
116
|
+
|
|
117
|
+
if event.connected?
|
|
118
|
+
connected(event)
|
|
119
|
+
elsif event.setup_cancelled?
|
|
120
|
+
setup_cancelled(event)
|
|
121
|
+
elsif event.stalled?
|
|
122
|
+
stalled(event)
|
|
123
|
+
elsif event.resumed?
|
|
124
|
+
resumed(event)
|
|
125
|
+
else
|
|
126
|
+
log_event(event)
|
|
127
|
+
end
|
|
128
|
+
connection.save!
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# At most this many records' pages are told to refresh when the connection stalls or resumes.
|
|
133
|
+
REFRESH_LIMIT = 200
|
|
134
|
+
|
|
135
|
+
# A stall or a resume changes what the run status says on the pages of the records with a run
|
|
136
|
+
# still going on this connection: tell them to refresh, after the change is committed (by the
|
|
137
|
+
# connection's runs index, at most REFRESH_LIMIT records).
|
|
138
|
+
def refresh_pages
|
|
139
|
+
return unless QubeRails.refresh_broadcasts?
|
|
140
|
+
|
|
141
|
+
going = connection.runs.where.not(state: Run::ENDED_STATES).distinct.limit(REFRESH_LIMIT)
|
|
142
|
+
.pluck(:record_type, :record_id)
|
|
143
|
+
going.each do |type, id|
|
|
144
|
+
model = type.safe_constantize
|
|
145
|
+
record = model.respond_to?(:find_by) ? model.find_by(model.primary_key => id) : nil
|
|
146
|
+
QubeRails.broadcast_refresh(record) if record
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
QubeRails.logger.warn("QuBe Sync: couldn't tell connection #{connection.id}'s pages to refresh (#{e.class})")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# All a handler does until you fill it in.
|
|
153
|
+
def log_event(event)
|
|
154
|
+
line = "QuBe Sync: connection #{connection.id}: #{event["event"]} (revision #{event["revision"]})"
|
|
155
|
+
if event["event"] == "connection.stalled"
|
|
156
|
+
QubeRails.logger.warn(line)
|
|
157
|
+
else
|
|
158
|
+
QubeRails.logger.info(line)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The event is about this connection: its connection_id is the one QuBe Sync made it.
|
|
163
|
+
def this_connection?(connection_id)
|
|
164
|
+
!connection_id.nil? && connection_id == connection.qube_id
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# The default dedupe setting: the notification delivery ids each run has taken, in
|
|
5
|
+
# qube_notifications. once runs inside the run row's lock and transaction (Operation#handle), so
|
|
6
|
+
# the row and the handler's writes commit together: a redelivery finds the row and is skipped,
|
|
7
|
+
# and a handler that raised rolls its row back with it, so the retry runs it again.
|
|
8
|
+
class DatabaseDedupe
|
|
9
|
+
# Runs the block unless run has taken delivery_id already.
|
|
10
|
+
def once(run, delivery_id)
|
|
11
|
+
notifications = QubeRails::Notification.where(run_id: run.id)
|
|
12
|
+
# Under the run row's lock no other worker inserts for this run, so this check is enough;
|
|
13
|
+
# the savepoint keeps a unique violation from a lock-free caller from aborting a
|
|
14
|
+
# PostgreSQL transaction.
|
|
15
|
+
return false if notifications.where(delivery_id: delivery_id.to_s).exists?
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
QubeRails::Notification.transaction(requires_new: true) do
|
|
19
|
+
notifications.create!(delivery_id: delivery_id.to_s)
|
|
20
|
+
end
|
|
21
|
+
rescue ActiveRecord::RecordNotUnique
|
|
22
|
+
return false
|
|
23
|
+
end
|
|
24
|
+
yield
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# Mounted in your config/routes.rb with `mount QubeRails::Engine => "/"`, which adds, relative
|
|
7
|
+
# to where it's mounted:
|
|
8
|
+
# POST quickbooks/connections a connection for the signed-in account
|
|
9
|
+
# POST quickbooks/connections/:id/onboarding one of its connections, through setup again
|
|
10
|
+
# POST webhooks/qube/runs/:id a run's webhooks: no sign-in, no CSRF
|
|
11
|
+
# POST webhooks/qube/connections/:id a connection's webhooks: no sign-in, no CSRF
|
|
12
|
+
# POST qube/questions/runs/:id an answer, behind your sign-in and CSRF
|
|
13
|
+
# GET qube/questions/runs/:id/search a record field's matches, behind sign-in
|
|
14
|
+
# POST qube/runs/:id/retry_start an unconfirmed start sent again, likewise
|
|
15
|
+
# GET qube/storyboard(/:screen) every screen with sample data (config.storyboard)
|
|
16
|
+
# Your views reach them through the qube_rails route proxy: qube_rails.connections_path,
|
|
17
|
+
# qube_rails.connection_onboarding_path(connection).
|
|
18
|
+
class Engine < ::Rails::Engine
|
|
19
|
+
isolate_namespace QubeRails
|
|
20
|
+
|
|
21
|
+
# qube_rails_assets and qube_classes, in every view of the app.
|
|
22
|
+
initializer "qube_rails.view_helpers" do
|
|
23
|
+
ActiveSupport.on_load(:action_view) { include QubeRails::ViewHelpers }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sprockets serves only what's listed; Propshaft serves every asset path and has no list.
|
|
27
|
+
initializer "qube_rails.assets" do |app|
|
|
28
|
+
assets = app.config.respond_to?(:assets) ? app.config.assets : nil
|
|
29
|
+
precompile = assets && assets.precompile
|
|
30
|
+
precompile.concat(%w[qube_rails/question.js qube_rails.css]) if precompile.is_a?(Array)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# How the JSON in qube_runs (question, links) is kept: as JSON text in a plain text column, with
|
|
7
|
+
# this coder.
|
|
8
|
+
#
|
|
9
|
+
# Why text and not a json column: the engine has to run on PostgreSQL, MySQL and SQLite, from
|
|
10
|
+
# Rails 5.2 (vendored into older apps) to 8.1, and the native types disagree. PostgreSQL has
|
|
11
|
+
# json and jsonb, MySQL 5.7+ has json (MariaDB's is a longtext alias, so it comes back as a
|
|
12
|
+
# String), and SQLite has no json type at all before Rails 6 maps one; what each adapter hands
|
|
13
|
+
# back (a Hash, or a String to parse) differs by adapter and version. Nothing ever queries inside
|
|
14
|
+
# these columns, so text and one coder behave the same everywhere, and the value is always a
|
|
15
|
+
# Hash with String keys, or nil.
|
|
16
|
+
module JSONCoder
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def dump(value)
|
|
20
|
+
value.nil? ? nil : JSON.generate(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load(text)
|
|
24
|
+
return nil if text.nil? || text == ""
|
|
25
|
+
return text unless text.is_a?(String) # a json column read by its adapter already
|
|
26
|
+
|
|
27
|
+
JSON.parse(text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# serialize(name, coder) until Rails 7.1, serialize(name, coder:) since: the positional form
|
|
31
|
+
# is deprecated in 7.1 and gone in 7.2.
|
|
32
|
+
def serialize(model, *names)
|
|
33
|
+
new_api = ActiveRecord.respond_to?(:gem_version) && ActiveRecord.gem_version >= Gem::Version.new("7.1")
|
|
34
|
+
names.each do |name|
|
|
35
|
+
if new_api
|
|
36
|
+
model.serialize(name, coder: self)
|
|
37
|
+
else
|
|
38
|
+
model.serialize(name, self)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|