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,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