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