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,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module QubeRails
6
+ # One way your app starts a workflow, and what it does with that run's webhooks: subclass it
7
+ # once per operation, in app/services/quickbooks_desktop/, and list it in config.operations by
8
+ # its SLUG.
9
+ #
10
+ # module QuickbooksDesktop
11
+ # class CreateInvoice < QubeRails::Operation
12
+ # WORKFLOW = "add_transaction"
13
+ # SLUG = "create_invoice"
14
+ #
15
+ # def input
16
+ # { operation: "invoice.create", params: { ref_number: record.number } }
17
+ # end
18
+ #
19
+ # def ended(event)
20
+ # txn = event.dig("output", "record") || {}
21
+ # connection.link(record, kind: "invoice", qb_id: txn["txn_id"],
22
+ # edit_sequence: txn["edit_sequence"], name: txn["ref_number"])
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ # run = QuickbooksDesktop::CreateInvoice.start(invoice, connection)
28
+ #
29
+ # Each run is a row in qube_runs (QubeRails::Run), written before the run starts, and its
30
+ # webhook URL is QUBE_WEBHOOK_URL/runs/<run id>, so the webhook job hands each event back to the
31
+ # same class with the record, the run and its connection found again. Your record's table
32
+ # needs no QuBe columns: the run keeps its reference, revision, state and question, and the
33
+ # QuickBooks ids an ending carries go on the connection (connection.link), keyed by kind.
34
+ class Operation
35
+ # Starts the operation's run for record, one your app has saved, on connection (a
36
+ # QubeRails::Connection the customer has finished onboarding, so it has a qube_id), and
37
+ # returns the run row. The row is written first, with a new reference
38
+ # (<slug>-<record id>-<uuid>) and state "starting", since a webhook can arrive before this
39
+ # returns, and keeps the input it was started with.
40
+ #
41
+ # The reference is also the Idempotency-Key. When QuBe Sync refuses the start (a 4xx: bad
42
+ # input, a workflow that isn't published) or the input raises, the row is marked "failed"
43
+ # and the error raised: nothing was started, and starting again makes a new run. When the
44
+ # start gets no answer (a timeout, a 5xx, a dropped connection) it may have started, so the
45
+ # row stays "starting" and the error is raised: send it again with run.retry_start!, never a
46
+ # new start, or a run that did start runs twice (two invoices).
47
+ #
48
+ # While record's last run of this operation on connection hasn't ended (completed, failed or
49
+ # cancelled), it starts nothing new: it returns that run (run.reused? is true), sending its
50
+ # start again first when QuBe Sync hasn't confirmed it, with the same reference and input. The
51
+ # reference is the Idempotency-Key, so a second click gets the same run back from QuBe Sync,
52
+ # never a second invoice. A new reference is made only once the last run has ended.
53
+ def self.start(record, connection)
54
+ unless made_by_qube_sync?(connection)
55
+ raise NotConnected, "QuBe Sync: #{name}.start needs a connection QuBe Sync has made (one with a " \
56
+ "qube_id): the customer finishes onboarding first"
57
+ end
58
+
59
+ run, input_error = claim_run(record, connection)
60
+ raise input_error if input_error
61
+ return retry_start(run) if run.reused?
62
+
63
+ send_start(run, run.input)
64
+ end
65
+
66
+ # Sends a start that got no answer again (run.retry_start! calls it): the same reference as
67
+ # the Idempotency-Key, the same workflow, input and webhook URL. QuBe Sync answers a repeat of
68
+ # a start it took with the run that start made (201, Idempotent-Replayed: true) rather than
69
+ # starting another, for as long as that run is kept (60 days after it ends); a start it never
70
+ # took starts now. Returns the run; a run already answered is returned as it is.
71
+ def self.retry_start(run)
72
+ return run if run.qube_run_id
73
+ unless run.state == "starting"
74
+ raise ArgumentError, "QuBe Sync: run #{run.id} is #{run.state}: only a start that got no answer is sent again"
75
+ end
76
+
77
+ send_start(run, run.input)
78
+ end
79
+
80
+ # The record's run that hasn't ended on connection (reused), or a new row with a new reference
81
+ # and its input, written together under the connection row's lock: two starts of one record at
82
+ # once (a double click) find the same row, so they send the same reference and input. Returns
83
+ # [run, error]: the error an input raised, raised once the lock's transaction has kept the row.
84
+ def self.claim_run(record, connection)
85
+ connection.with_lock do
86
+ going = QubeRails::Run.where(connection: connection).latest_for(record, self::SLUG)
87
+ next [going.tap { |run| run.reused = true }, nil] if going && !going.ended?
88
+
89
+ new_run(record, connection)
90
+ end
91
+ end
92
+ private_class_method :claim_run
93
+
94
+ # [run, nil], or for an input that raises, the run kept as "failed" (nothing was started) and
95
+ # the error.
96
+ def self.new_run(record, connection)
97
+ run = QubeRails::Run.new(
98
+ connection: connection, operation: self::SLUG, record: record,
99
+ reference: "#{self::SLUG}-#{record.id}-#{SecureRandom.uuid}", state: "starting", revision: 0
100
+ )
101
+ begin
102
+ # As JSON reads it back, so a retry sends exactly what the first start did.
103
+ run.input = JSONCoder.load(JSONCoder.dump(new(record, run).input))
104
+ rescue StandardError, NotImplementedError => e # an input not written yet, too
105
+ run.update!(state: "failed")
106
+ return [run, e]
107
+ end
108
+ run.save!
109
+ [run, nil]
110
+ end
111
+ private_class_method :new_run
112
+
113
+ def self.send_start(run, input)
114
+ started =
115
+ begin
116
+ QubeRails.client.start_run(run.connection.qube_id, self::WORKFLOW, input,
117
+ reference: run.reference, webhook_url: run.webhook_url)
118
+ rescue Backend::APIError => e
119
+ mark_failed(run) if refused?(e)
120
+ raise RealtimeRequired, refusal_detail(e) if refusal_code(e) == "realtime_required"
121
+
122
+ raise
123
+ end
124
+ record_start(run, started.is_a?(Hash) ? started : {})
125
+ run
126
+ end
127
+ private_class_method :send_start
128
+
129
+ # A 4xx: nothing started. But 409, the Idempotency-Key in use by another call still in flight
130
+ # (the other click of a double click): that start may land, so the run stays "starting".
131
+ def self.refused?(error)
132
+ error.status.between?(400, 499) && error.status != 409
133
+ end
134
+ private_class_method :refused?
135
+
136
+ # errors.code and errors.detail, from the gem's APIError or the inline client's body.
137
+ def self.refusal_code(error)
138
+ return error.code if error.respond_to?(:code)
139
+
140
+ refusal_errors(error)["code"]
141
+ end
142
+ private_class_method :refusal_code
143
+
144
+ def self.refusal_detail(error)
145
+ errors = error.respond_to?(:errors) && error.errors.is_a?(Hash) ? error.errors : refusal_errors(error)
146
+ errors["detail"] || "Workflows run on realtime connections, and this one is standard."
147
+ end
148
+ private_class_method :refusal_detail
149
+
150
+ def self.refusal_errors(error)
151
+ parsed = JSON.parse(error.body.to_s)
152
+ parsed.is_a?(Hash) && parsed["errors"].is_a?(Hash) ? parsed["errors"] : {}
153
+ rescue JSON::ParserError
154
+ {}
155
+ end
156
+ private_class_method :refusal_errors
157
+
158
+ def self.mark_failed(run)
159
+ run.update_columns(state: "failed", updated_at: Time.now)
160
+ end
161
+ private_class_method :mark_failed
162
+
163
+ # The run's id, links and state from QuBe Sync's answer. Its webhooks may have come first, so
164
+ # the state is taken only while the row still says "starting".
165
+ def self.record_start(run, started)
166
+ links = started["links"].is_a?(Hash) ? started["links"] : {}
167
+ run.with_lock do
168
+ run.qube_run_id = started["id"]
169
+ run.run_url = links["run"]
170
+ run.cancel_url = links["cancel"]
171
+ run.state = started["state"] if run.state == "starting" && started["state"]
172
+ run.save!
173
+ end
174
+ end
175
+ private_class_method :record_start
176
+
177
+ # A QubeRails::Connection QuBe Sync has made: one with a qube_id.
178
+ def self.made_by_qube_sync?(connection)
179
+ connection.is_a?(QubeRails::Connection) && !connection.qube_id.to_s.empty?
180
+ end
181
+ private_class_method :made_by_qube_sync?
182
+
183
+ # Your record, by the id its run keeps (a String). Leave it out and the run finds it by its
184
+ # record_type (run.record); write it to load what input and the handlers read with it:
185
+ # def self.find_record(id)
186
+ # Invoice.includes(lines: :item).find(id)
187
+ # end
188
+ def self.find_record(id)
189
+ raise NotImplementedError, "#{name}.find_record(#{id.inspect}) isn't written: leave it out to " \
190
+ "find the record by the run's record_type"
191
+ end
192
+
193
+ # The run's record: find_record when the operation writes one, else the run's own lookup.
194
+ def self.record_for(run)
195
+ if method(:find_record).owner == QubeRails::Operation.singleton_class
196
+ run.record
197
+ else
198
+ find_record(run.record_id)
199
+ end
200
+ end
201
+
202
+ attr_reader :record, :run
203
+
204
+ def initialize(record, run)
205
+ @record = record
206
+ @run = run
207
+ end
208
+
209
+ # The connection the run is on (a QubeRails::Connection): where the QuickBooks ids an ending
210
+ # carries are kept (connection.link), and where the ones the input names are read
211
+ # (connection.qb_id_for).
212
+ def connection
213
+ run.connection
214
+ end
215
+
216
+ # When this connection's last completed run of WORKFLOW started (nil before one): an
217
+ # incremental sync's since.
218
+ def last_synced_at
219
+ connection.last_synced_at(self.class::WORKFLOW)
220
+ end
221
+
222
+ # The run's input, from the record. Each operation says how. It returns a Hash; with typed
223
+ # workflow starts (`bin/rails g qube_rails:workflows`, which writes app/qube/qube_workflows.rb),
224
+ # it can return the workflow's own input method's, whose operation bodies are typed and
225
+ # documented as the operation's params:
226
+ # def input
227
+ # QubeWorkflows::AddTransaction.invoice_input({ customer_ref: quickbooks_ref(...), ... })
228
+ # end
229
+ # whose RBS type is QubeWorkflows::AddTransaction::invoice_input (the workflow's module, then
230
+ # <branch>_input, or input for a workflow whose input has no branches).
231
+ def input
232
+ raise NotImplementedError, "#{self.class.name}#input isn't written yet"
233
+ end
234
+
235
+ # One of the run's webhooks, from the webhook job, as the qube_sync gem parsed it: a
236
+ # WorkflowRunEvent or a NotificationEvent. Deliveries come at least once and, from a queue
237
+ # with more than one worker, in any order:
238
+ # - an event whose reference isn't the run's, or whose connection_id isn't its connection's
239
+ # qube_id (a signed body posted to another run's URL), is logged and skipped;
240
+ # - a notification is taken once, by its delivery_id (in qube_notifications, with what the
241
+ # handler writes; the dedupe setting);
242
+ # - any other event is applied only when its revision is newer than the last one applied.
243
+ # It holds the run row's lock while it does (with_lock, which reads it again), so two workers
244
+ # never apply one run's events at once, and what a handler writes is saved with the revision
245
+ # or not at all.
246
+ #
247
+ # Once an event has changed the run, pages showing record's run (the engine's partials) are
248
+ # told to refresh (QubeRails.broadcast_refresh), so a new question or an ending shows without
249
+ # a reload in an app with turbo-rails.
250
+ def handle(event)
251
+ before = run.revision
252
+ run.with_lock { apply(event) }
253
+ QubeRails.broadcast_refresh(record) unless run.revision == before
254
+ end
255
+
256
+ # A question the run asks: kept whole on the run, with its links (where to answer it, and
257
+ # where a record field searches), for the question partial (qube_rails/_question) to show on
258
+ # the record's page. Your user's choice answers it, through the question routes: nothing here
259
+ # picks one, and the run waits for as long as it takes. A newer event that isn't a question
260
+ # forgets it.
261
+ def question(event)
262
+ prompt = event.prompt
263
+ return unless prompt # answered already: this delivery was a retry
264
+
265
+ links = event.links || {}
266
+ run.question = prompt
267
+ run.links = { "decisions" => links["decisions"], "search" => links["search"] }
268
+ end
269
+
270
+ # What an operation does with the rest of its run's events: each operation says, and until it
271
+ # does, each logs. The engine has already kept every ending on the run (state, outcome, error,
272
+ # cancel_reason, ended_at: the qube_rails/run_status partial shows it), so these are for your
273
+ # own records: an invoice marked sent, a sync's last-finished time.
274
+ def notification(event)
275
+ log_event(event)
276
+ end
277
+
278
+ def ended(event)
279
+ log_event(event)
280
+ end
281
+
282
+ def failed(event)
283
+ log_event(event)
284
+ end
285
+
286
+ # How the input refers to one of your records in QuickBooks: { list_id: } when it's linked on
287
+ # this run's connection (connection.qb_id_for), else { full_name: name }, or nil when there's
288
+ # neither. Refer by id where you can: a name can match another record, or none after a
289
+ # rename.
290
+ # customer_ref: quickbooks_ref(record.customer, kind: "customer", name: record.customer&.name)
291
+ def quickbooks_ref(app_record, kind:, name: nil)
292
+ list_id = app_record && connection.qb_id_for(app_record, kind: kind)
293
+ return { list_id: list_id } if list_id
294
+ return nil if name.nil? || name.to_s.empty?
295
+
296
+ { full_name: name }
297
+ end
298
+
299
+ # Links each QuickBooks record an ending carries (event.output["references"], keyed by the
300
+ # input's path: "customer_ref", "invoice_line_add.0.item_ref") to the record of yours your
301
+ # block returns for it, on this run's connection, so the next run refers to it by id. The
302
+ # block gets the path and the entry (kind, outcome and record: the record's own list as
303
+ # "type", with list_id, edit_sequence and full_name) and returns your record, or nil to skip
304
+ # it. An entry with no record (not found, or not reached before a cancel) is skipped. Works on
305
+ # an added ending and on a cancelled one, which can carry some. Returns the rows linked.
306
+ # link_references(event) do |path, entry|
307
+ # record.customer if path == "customer_ref"
308
+ # end
309
+ def link_references(event)
310
+ output = event["output"]
311
+ references = output.is_a?(Hash) ? output["references"] : nil
312
+ return [] unless references.is_a?(Hash)
313
+
314
+ references.each_with_object([]) do |(path, entry), linked|
315
+ next unless entry.is_a?(Hash) && entry["record"].is_a?(Hash)
316
+
317
+ found = entry["record"]
318
+ next if found["list_id"].to_s.empty?
319
+
320
+ app_record = yield(path, entry)
321
+ next unless app_record
322
+
323
+ kind = found["type"].to_s.empty? ? entry["kind"] : found["type"]
324
+ linked << connection.link(app_record, kind: kind, qb_id: found["list_id"],
325
+ edit_sequence: found["edit_sequence"], name: found["full_name"])
326
+ end
327
+ end
328
+
329
+ # After your user's choice is in: the question is forgotten, unless the run has asked another
330
+ # since (its token is the question's own). taken: QuBe Sync took the answer, so the run is
331
+ # running again until its next webhook (which waits on the Web Connector's next poll).
332
+ def answered(token, taken: false)
333
+ run.with_lock do
334
+ question = run.open_question
335
+ if question && question["token"] == token
336
+ run.question = nil
337
+ run.links = nil
338
+ run.state = "running" if taken
339
+ run.save!
340
+ end
341
+ end
342
+ end
343
+
344
+ private
345
+
346
+ # Only a run's events and its notifications carry a reference: any other event posted to this
347
+ # URL is skipped with the rest that aren't this run's.
348
+ def apply(event)
349
+ unless this_run?(event)
350
+ QubeRails.logger.warn("QuBe Sync: #{self.class.name} run #{run.id} skipped #{event.event} for " \
351
+ "#{event.reference.inspect} on #{event["connection_id"].inspect}: not this run")
352
+ return
353
+ end
354
+
355
+ if event.type == "notification"
356
+ take_once(event.dedupe_key) { notification(event) } # its delivery_id
357
+ return
358
+ end
359
+ return unless event.type == "workflow_run"
360
+ # Applied already, or older than what was.
361
+ return unless event.newer_than?(run.revision)
362
+
363
+ # The run as of this event, before your handler runs, so every ending is on the run row
364
+ # whatever the handler does: state, outcome, and a failure's error or a cancel's reason.
365
+ run.state = event.run_state || run_state_for(event)
366
+ run.outcome = event.outcome
367
+ if ended?(event)
368
+ run.ended_at ||= Time.now
369
+ run.error = event.error if event.failed?
370
+ run.cancel_reason = event.cancel_reason if event.cancelled?
371
+ end
372
+
373
+ if event.awaiting_input?
374
+ question(event)
375
+ elsif event.completed?
376
+ QubeRails::SyncMark.advance(connection, self.class::WORKFLOW, run.created_at)
377
+ ended(event)
378
+ elsif stopped?(event)
379
+ failed(event)
380
+ end
381
+ # A newer event that isn't a question: the one saved was answered, or the run ended.
382
+ unless event.awaiting_input?
383
+ run.question = nil
384
+ run.links = nil
385
+ end
386
+ run.revision = event.revision
387
+ run.save!
388
+ end
389
+
390
+ # The event names this run: its reference, on its connection.
391
+ def this_run?(event)
392
+ same_reference?(event) && on_this_connection?(event)
393
+ end
394
+
395
+ def same_reference?(event)
396
+ !event.reference.nil? && event.reference == run.reference
397
+ end
398
+
399
+ def on_this_connection?(event)
400
+ qube_id = run.connection.qube_id
401
+ !qube_id.nil? && event["connection_id"] == qube_id
402
+ end
403
+
404
+ # The run is over: completed, failed or cancelled.
405
+ def ended?(event)
406
+ event.completed? || stopped?(event)
407
+ end
408
+
409
+ # The run ended without completing: failed or cancelled, which the failed handler takes.
410
+ def stopped?(event)
411
+ event.failed? || event.cancelled?
412
+ end
413
+
414
+ def run_state_for(event)
415
+ if event.awaiting_input? then "awaiting_input"
416
+ elsif event.completed? then "completed"
417
+ elsif event.failed? then "failed"
418
+ elsif event.cancelled? then "cancelled"
419
+ else run.state
420
+ end
421
+ end
422
+
423
+ # Runs the block once per delivery_id of this run, kept in the dedupe store (config.dedupe).
424
+ # The default store writes its row inside this run's lock and transaction, so the row and the
425
+ # handler's writes commit together, or neither does.
426
+ def take_once(delivery_id)
427
+ store = QubeRails.config.dedupe
428
+ return store.once(run, delivery_id) { yield } if store.respond_to?(:once)
429
+
430
+ key = "qube-sync/delivery/#{delivery_id}"
431
+ return if store.seen?(key) # a retry of one already taken
432
+
433
+ yield
434
+ store.remember(key)
435
+ end
436
+
437
+ # The input without its nil values, at every depth: what a mapped operation's input sends, so
438
+ # a field your record doesn't have yet is left out rather than sent as null.
439
+ def without_nils(value)
440
+ case value
441
+ when Hash
442
+ value.each_with_object({}) do |(key, inner), kept|
443
+ kept[key] = without_nils(inner) unless inner.nil?
444
+ end
445
+ when Array
446
+ value.compact.map { |inner| without_nils(inner) }
447
+ else
448
+ value
449
+ end
450
+ end
451
+
452
+ # All a handler does until you fill it in.
453
+ def log_event(event)
454
+ detail = event.dig("prompt", "id") || event["name"] || event["outcome"]
455
+ line = "QuBe Sync: #{self.class.name} #{record.id} (run #{run.id}): #{event.event} #{detail} " \
456
+ "(#{event.reference})"
457
+ if event.type == "workflow_run" && stopped?(event)
458
+ QubeRails.logger.warn(line)
459
+ else
460
+ QubeRails.logger.info(line)
461
+ end
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "qube_rails/rows_view"
4
+
5
+ module QubeRails
6
+ # The open question of a run, as the question partials render it: the override contract, kept
7
+ # stable across versions (README, "Styling"). Built from the prompt QuBe Sync sent.
8
+ class QuestionView
9
+ attr_reader :prompt, :message, :details, :choices, :token, :action_url, :search_url, :error
10
+
11
+ # The question run is asking, or nil when it isn't asking. urls answers question_path(run):
12
+ # the engine's route proxy (qube_rails) in a view.
13
+ def self.from_run(run, urls, error: nil)
14
+ prompt = run && run.open_question
15
+ return nil unless prompt
16
+
17
+ action = urls.question_path(run)
18
+ new(prompt, action_url: action, search_url: "#{action}/search", error: error)
19
+ end
20
+
21
+ def initialize(prompt, action_url:, search_url:, error: nil)
22
+ @prompt = prompt
23
+ @message = prompt["message"].to_s
24
+ @token = prompt["token"].to_s
25
+ @details = Array(prompt["details"]).select { |row| row.is_a?(Hash) }
26
+ .map { |row| DetailView.new(row["label"], row["value"]) }
27
+ @action_url = action_url
28
+ @search_url = search_url
29
+ @error = error
30
+ @choices = Array(prompt["choices"]).select { |c| c.is_a?(Hash) }.each_with_index.map do |choice, n|
31
+ ChoiceView.new(choice, n, self)
32
+ end
33
+ arrange
34
+ end
35
+
36
+ # The choices by where they show: picks (option rows), forms (a section each, "or" between),
37
+ # then exits (a footer row). plain? when there's neither a pick nor a form: one row, the first
38
+ # choice primary.
39
+ def picks
40
+ choices.select { |c| c.kind == :pick }
41
+ end
42
+
43
+ def forms
44
+ choices.select { |c| c.kind == :form }
45
+ end
46
+
47
+ def exits
48
+ choices.select { |c| c.kind == :exit }
49
+ end
50
+
51
+ def plain?
52
+ picks.empty? && forms.empty?
53
+ end
54
+
55
+ private
56
+
57
+ # Kinds and button styles. With no picks but a form, a plain choice before the first form
58
+ # (name clash's "Use the existing record") is an option row too. One filled button at most:
59
+ # the first form's submit, or in a plain question the first choice.
60
+ def arrange
61
+ if @choices.none? { |c| c.kind == :pick } && (first_form = @choices.index { |c| c.kind == :form })
62
+ @choices.first(first_form).each { |c| c.arrange(:pick, nil) unless c.secondary? }
63
+ end
64
+ primary = plain? ? @choices.find { |c| !c.secondary? } : forms.first
65
+ @choices.each do |choice|
66
+ style = if choice.kind == :pick then nil
67
+ elsif choice.equal?(primary) then :primary
68
+ elsif choice.secondary? then :ghost
69
+ else :secondary
70
+ end
71
+ choice.arrange(choice.kind, style)
72
+ end
73
+ end
74
+ end
75
+
76
+ # One label and value row of what the question is about.
77
+ DetailView = Struct.new(:label, :value)
78
+
79
+ # One of a select's options.
80
+ OptionView = Struct.new(:value, :label)
81
+
82
+ # One thing the user can pick: a form of its own, posting choice=<id> and data[...] for its
83
+ # fields to the question's action_url.
84
+ class ChoiceView
85
+ attr_reader :id, :label, :detail, :description, :fields, :index, :action_url, :kind, :style
86
+
87
+ def initialize(choice, index, question)
88
+ @id = choice["id"].to_s
89
+ @label = choice["label"].to_s
90
+ @detail = presence(choice["detail"])
91
+ @description = presence(choice["description"])
92
+ @index = index
93
+ @action_url = question.action_url
94
+ @fields = Array(choice["fields"]).select { |f| f.is_a?(Hash) }.map do |field|
95
+ FieldView.from_prompt(field, question, index)
96
+ end
97
+ @kind = if fields.any? then :form
98
+ elsif @id.match?(/:\d+\z/) then :pick
99
+ else :exit
100
+ end
101
+ end
102
+
103
+ # kind is :pick (an option row: an item of a choose list, id "<option>:<n>", no fields), :form
104
+ # (a choice with fields) or :exit (none, cancel and the like). style is its button's: :primary,
105
+ # :secondary or :ghost (cancel), nil for a pick. Set by the QuestionView.
106
+ def arrange(kind, style)
107
+ @kind = kind
108
+ @style = style
109
+ end
110
+
111
+ # The ui_classes piece for its button.
112
+ def button_piece
113
+ case style
114
+ when :primary then :button
115
+ when :ghost then QubeRails.config.ui_classes.key?(:button_ghost) ? :button_ghost : :button_secondary
116
+ else :button_secondary
117
+ end
118
+ end
119
+
120
+ # A choice that stops (cancel): the ui_classes button_secondary rather than button.
121
+ def secondary?
122
+ id == "cancel"
123
+ end
124
+
125
+ private
126
+
127
+ def presence(value)
128
+ text = value.to_s
129
+ text.strip.empty? ? nil : text
130
+ end
131
+ end
132
+
133
+ # One input: a choice's field, or a field of a rows field's row.
134
+ # type is "text", "number", "boolean", "select", "search" or "rows"; partial is the piece that
135
+ # renders it (qube_rails/question/fields/_<type>).
136
+ class FieldView
137
+ TYPES = %w[text number boolean select search rows].freeze
138
+
139
+ attr_reader :name, :input_name, :dom_id, :type, :label, :required, :options, :kind,
140
+ :search_url, :search_field, :row_key, :when, :rows, :context, :disabled
141
+
142
+ # A choice's field, as QuBe Sync sends it.
143
+ def self.from_prompt(field, question, choice_index)
144
+ name = field["name"].to_s
145
+ input_name = "data[#{name}]"
146
+ type = type_of(field, record_type: "search")
147
+ rows = if type == "rows"
148
+ RowsView.new(field, input_name: input_name, search_url: question.search_url)
149
+ end
150
+ new(name: name, input_name: input_name, dom_id: "qube-#{question.token}-#{choice_index}-#{name}",
151
+ type: type, label: field["label"] || name, required: field["required"] == true,
152
+ options: options_of(field), kind: field["kind"], search_url: question.search_url,
153
+ search_field: name, rows: rows)
154
+ end
155
+
156
+ # What a prompt field shows as: a record a search, an enum a select, else by its type.
157
+ def self.type_of(field, record_type:)
158
+ return "rows" if field["type"] == "rows"
159
+ return record_type if field["type"] == "record"
160
+ return "select" if field["enum"].is_a?(Array) && !field["enum"].empty?
161
+ return field["type"] if %w[boolean number].include?(field["type"])
162
+
163
+ "text"
164
+ end
165
+
166
+ def self.options_of(field)
167
+ Array(field["enum"]).select { |e| e.is_a?(Hash) }.map do |e|
168
+ OptionView.new(e["value"], (e["label"] || e["value"]).to_s)
169
+ end
170
+ end
171
+
172
+ # context is :choice (a choice's field) or :cell (a field of a rows field's row); both show
173
+ # their label.
174
+ def initialize(name:, type:, label:, input_name: nil, dom_id: nil, required: false, options: [], kind: nil,
175
+ search_url: nil, search_field: nil, row_key: nil, when_values: nil, rows: nil,
176
+ context: :choice, disabled: false)
177
+ raise ArgumentError, "QuBe Sync: no field type #{type.inspect}" unless TYPES.include?(type)
178
+
179
+ @name = name
180
+ @type = type
181
+ @label = label.to_s
182
+ @input_name = input_name
183
+ @dom_id = dom_id
184
+ @required = required
185
+ @options = options
186
+ @kind = kind
187
+ @search_url = search_url
188
+ @search_field = search_field
189
+ @row_key = row_key
190
+ @when = when_values || {}
191
+ @rows = rows
192
+ @context = context
193
+ @disabled = disabled
194
+ end
195
+
196
+ def partial
197
+ "qube_rails/question/fields/#{type}"
198
+ end
199
+
200
+ def cell?
201
+ context == :cell
202
+ end
203
+
204
+ # Whether it's shown with its own label: always, now that a row's fields stack.
205
+ def labelled?
206
+ true
207
+ end
208
+
209
+ # The input's data attributes the behaviour script keys on.
210
+ def data
211
+ cell? ? { "qube-rows-input" => "" } : {}
212
+ end
213
+
214
+ # A select with at most three options in a row shows as a segmented control.
215
+ def segmented?
216
+ cell? && type == "select" && options.size.between?(1, 3)
217
+ end
218
+ end
219
+ end