webhookdb 1.3.1 → 1.5.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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/admin-dist/assets/{index-6aebf805.js → index-9306dd28.js} +39 -39
  3. data/admin-dist/index.html +1 -1
  4. data/data/messages/templates/errors/generic_backfill.email.liquid +30 -0
  5. data/data/messages/templates/errors/icalendar_fetch.email.liquid +8 -2
  6. data/data/messages/templates/specs/with_fields.email.liquid +6 -0
  7. data/db/migrations/026_undo_integration_backfill_cursor.rb +2 -0
  8. data/db/migrations/032_remove_db_defaults.rb +2 -0
  9. data/db/migrations/043_text_search.rb +2 -0
  10. data/db/migrations/045_system_log.rb +15 -0
  11. data/db/migrations/046_indices.rb +14 -0
  12. data/db/migrations/047_sync_parallelism.rb +9 -0
  13. data/db/migrations/048_sync_stats.rb +9 -0
  14. data/db/migrations/049_error_handlers.rb +18 -0
  15. data/db/migrations/050_logged_webhook_indices.rb +25 -0
  16. data/db/migrations/051_partitioning.rb +9 -0
  17. data/integration/async_spec.rb +0 -2
  18. data/integration/service_integrations_spec.rb +0 -2
  19. data/lib/amigo/durable_job.rb +2 -2
  20. data/lib/amigo/job_in_context.rb +12 -0
  21. data/lib/webhookdb/admin.rb +6 -0
  22. data/lib/webhookdb/admin_api/data_provider.rb +1 -0
  23. data/lib/webhookdb/admin_api/entities.rb +8 -0
  24. data/lib/webhookdb/aggregate_result.rb +1 -1
  25. data/lib/webhookdb/api/entities.rb +6 -2
  26. data/lib/webhookdb/api/error_handlers.rb +104 -0
  27. data/lib/webhookdb/api/helpers.rb +25 -1
  28. data/lib/webhookdb/api/icalproxy.rb +22 -0
  29. data/lib/webhookdb/api/install.rb +2 -1
  30. data/lib/webhookdb/api/organizations.rb +6 -0
  31. data/lib/webhookdb/api/saved_queries.rb +1 -0
  32. data/lib/webhookdb/api/saved_views.rb +1 -0
  33. data/lib/webhookdb/api/service_integrations.rb +2 -1
  34. data/lib/webhookdb/api/sync_targets.rb +1 -1
  35. data/lib/webhookdb/api/system.rb +5 -0
  36. data/lib/webhookdb/api/webhook_subscriptions.rb +1 -0
  37. data/lib/webhookdb/api.rb +4 -1
  38. data/lib/webhookdb/apps.rb +4 -0
  39. data/lib/webhookdb/async/autoscaler.rb +10 -0
  40. data/lib/webhookdb/async/job.rb +4 -0
  41. data/lib/webhookdb/async/scheduled_job.rb +4 -0
  42. data/lib/webhookdb/async.rb +2 -0
  43. data/lib/webhookdb/backfiller.rb +17 -4
  44. data/lib/webhookdb/concurrent.rb +96 -0
  45. data/lib/webhookdb/connection_cache.rb +57 -10
  46. data/lib/webhookdb/console.rb +1 -1
  47. data/lib/webhookdb/customer/reset_code.rb +1 -1
  48. data/lib/webhookdb/customer.rb +5 -4
  49. data/lib/webhookdb/database_document.rb +1 -1
  50. data/lib/webhookdb/db_adapter/default_sql.rb +1 -14
  51. data/lib/webhookdb/db_adapter/partition.rb +14 -0
  52. data/lib/webhookdb/db_adapter/partitioning.rb +8 -0
  53. data/lib/webhookdb/db_adapter/pg.rb +77 -5
  54. data/lib/webhookdb/db_adapter/snowflake.rb +15 -6
  55. data/lib/webhookdb/db_adapter.rb +25 -3
  56. data/lib/webhookdb/dbutil.rb +2 -0
  57. data/lib/webhookdb/errors.rb +34 -0
  58. data/lib/webhookdb/fixtures/logged_webhooks.rb +4 -0
  59. data/lib/webhookdb/fixtures/organization_error_handlers.rb +20 -0
  60. data/lib/webhookdb/http.rb +30 -16
  61. data/lib/webhookdb/icalendar.rb +30 -9
  62. data/lib/webhookdb/jobs/amigo_test_jobs.rb +1 -1
  63. data/lib/webhookdb/jobs/backfill.rb +21 -25
  64. data/lib/webhookdb/jobs/create_mirror_table.rb +3 -4
  65. data/lib/webhookdb/jobs/deprecated_jobs.rb +3 -0
  66. data/lib/webhookdb/jobs/emailer.rb +2 -1
  67. data/lib/webhookdb/jobs/front_signalwire_message_channel_sync_inbound.rb +15 -0
  68. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +7 -2
  69. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +74 -11
  70. data/lib/webhookdb/jobs/icalendar_enqueue_syncs_for_urls.rb +22 -0
  71. data/lib/webhookdb/jobs/icalendar_sync.rb +21 -9
  72. data/lib/webhookdb/jobs/increase_event_handler.rb +3 -2
  73. data/lib/webhookdb/jobs/{logged_webhook_replay.rb → logged_webhooks_replay.rb} +5 -3
  74. data/lib/webhookdb/jobs/message_dispatched.rb +1 -0
  75. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +112 -0
  76. data/lib/webhookdb/jobs/monitor_metrics.rb +29 -0
  77. data/lib/webhookdb/jobs/organization_database_migration_notify.rb +32 -0
  78. data/lib/webhookdb/jobs/organization_database_migration_run.rb +4 -6
  79. data/lib/webhookdb/jobs/organization_error_handler_dispatch.rb +26 -0
  80. data/lib/webhookdb/jobs/prepare_database_connections.rb +1 -0
  81. data/lib/webhookdb/jobs/process_webhook.rb +11 -12
  82. data/lib/webhookdb/jobs/renew_watch_channel.rb +10 -10
  83. data/lib/webhookdb/jobs/replication_migration.rb +5 -2
  84. data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +1 -2
  85. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -2
  86. data/lib/webhookdb/jobs/send_invite.rb +3 -2
  87. data/lib/webhookdb/jobs/send_test_webhook.rb +1 -3
  88. data/lib/webhookdb/jobs/send_webhook.rb +4 -5
  89. data/lib/webhookdb/jobs/stale_row_deleter.rb +31 -0
  90. data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +3 -0
  91. data/lib/webhookdb/jobs/sync_target_run_sync.rb +9 -15
  92. data/lib/webhookdb/jobs/{webhook_subscription_delivery_attempt.rb → webhook_subscription_delivery_event.rb} +5 -8
  93. data/lib/webhookdb/liquid/expose.rb +1 -1
  94. data/lib/webhookdb/liquid/filters.rb +1 -1
  95. data/lib/webhookdb/liquid/partial.rb +2 -2
  96. data/lib/webhookdb/logged_webhook/resilient.rb +3 -3
  97. data/lib/webhookdb/logged_webhook.rb +16 -2
  98. data/lib/webhookdb/message/email_transport.rb +1 -1
  99. data/lib/webhookdb/message/transport.rb +1 -1
  100. data/lib/webhookdb/message.rb +55 -4
  101. data/lib/webhookdb/messages/error_generic_backfill.rb +47 -0
  102. data/lib/webhookdb/messages/error_icalendar_fetch.rb +5 -0
  103. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +2 -0
  104. data/lib/webhookdb/messages/specs.rb +16 -0
  105. data/lib/webhookdb/organization/alerting.rb +56 -6
  106. data/lib/webhookdb/organization/database_migration.rb +2 -2
  107. data/lib/webhookdb/organization/db_builder.rb +5 -4
  108. data/lib/webhookdb/organization/error_handler.rb +141 -0
  109. data/lib/webhookdb/organization.rb +76 -10
  110. data/lib/webhookdb/postgres/model.rb +1 -0
  111. data/lib/webhookdb/postgres/model_utilities.rb +2 -0
  112. data/lib/webhookdb/postgres.rb +3 -4
  113. data/lib/webhookdb/replicator/base.rb +202 -68
  114. data/lib/webhookdb/replicator/base_stale_row_deleter.rb +165 -0
  115. data/lib/webhookdb/replicator/column.rb +2 -0
  116. data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +0 -1
  117. data/lib/webhookdb/replicator/fake.rb +106 -88
  118. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +131 -61
  119. data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +17 -0
  120. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +197 -32
  121. data/lib/webhookdb/replicator/icalendar_event_v1.rb +20 -44
  122. data/lib/webhookdb/replicator/icalendar_event_v1_partitioned.rb +33 -0
  123. data/lib/webhookdb/replicator/intercom_contact_v1.rb +1 -0
  124. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +1 -0
  125. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +49 -6
  126. data/lib/webhookdb/replicator/partitionable_mixin.rb +116 -0
  127. data/lib/webhookdb/replicator/shopify_v1_mixin.rb +1 -1
  128. data/lib/webhookdb/replicator/signalwire_message_v1.rb +31 -1
  129. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  130. data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +0 -1
  131. data/lib/webhookdb/replicator/transistor_episode_v1.rb +11 -5
  132. data/lib/webhookdb/replicator/webhook_request.rb +8 -0
  133. data/lib/webhookdb/replicator.rb +6 -3
  134. data/lib/webhookdb/service/helpers.rb +4 -0
  135. data/lib/webhookdb/service/middleware.rb +6 -2
  136. data/lib/webhookdb/service/view_api.rb +1 -1
  137. data/lib/webhookdb/service.rb +10 -10
  138. data/lib/webhookdb/service_integration.rb +19 -1
  139. data/lib/webhookdb/signalwire.rb +1 -1
  140. data/lib/webhookdb/spec_helpers/async.rb +0 -4
  141. data/lib/webhookdb/spec_helpers/sentry.rb +32 -0
  142. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +239 -64
  143. data/lib/webhookdb/spec_helpers.rb +1 -0
  144. data/lib/webhookdb/sync_target.rb +202 -34
  145. data/lib/webhookdb/system_log_event.rb +9 -0
  146. data/lib/webhookdb/tasks/admin.rb +1 -1
  147. data/lib/webhookdb/tasks/annotate.rb +1 -1
  148. data/lib/webhookdb/tasks/db.rb +13 -1
  149. data/lib/webhookdb/tasks/docs.rb +1 -1
  150. data/lib/webhookdb/tasks/fixture.rb +1 -1
  151. data/lib/webhookdb/tasks/message.rb +1 -1
  152. data/lib/webhookdb/tasks/regress.rb +1 -1
  153. data/lib/webhookdb/tasks/release.rb +1 -1
  154. data/lib/webhookdb/tasks/sidekiq.rb +1 -1
  155. data/lib/webhookdb/tasks/specs.rb +1 -1
  156. data/lib/webhookdb/version.rb +1 -1
  157. data/lib/webhookdb/webhook_subscription.rb +3 -4
  158. data/lib/webhookdb.rb +34 -8
  159. metadata +114 -64
  160. data/lib/webhookdb/jobs/customer_created_notify_internal.rb +0 -22
  161. data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +0 -21
  162. data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +0 -21
  163. /data/lib/webhookdb/jobs/{logged_webhook_resilient_replay.rb → logged_webhooks_resilient_replay.rb} +0 -0
  164. /data/lib/webhookdb/jobs/{webhook_resource_notify_integrations.rb → webhookdb_resource_notify_integrations.rb} +0 -0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "webhookdb/replicator/partitionable_mixin"
4
+
3
5
  class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
4
6
  extend Webhookdb::MethodUtilities
5
7
 
@@ -10,18 +12,24 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
10
12
  singleton_attr_accessor :process_webhooks_synchronously
11
13
  singleton_attr_accessor :obfuscate_headers_for_logging
12
14
  singleton_attr_accessor :requires_sequence
15
+ singleton_attr_accessor :extra_index_specs
13
16
 
14
- def self.descriptor
15
- return Webhookdb::Replicator::Descriptor.new(
16
- name: "fake_v1",
17
- ctor: ->(sint) { Webhookdb::Replicator::Fake.new(sint) },
17
+ def self._descriptor(**kw)
18
+ clsname = self.name.split("::").last
19
+ opts = {
20
+ name: clsname.underscore + "_v1",
21
+ ctor: ->(sint) { self.new(sint) },
18
22
  feature_roles: ["internal"],
19
- resource_name_singular: "Fake",
23
+ resource_name_singular: clsname,
20
24
  supports_webhooks: true,
21
25
  supports_backfill: true,
22
- )
26
+ }
27
+ opts.merge!(kw)
28
+ return Webhookdb::Replicator::Descriptor.new(**opts)
23
29
  end
24
30
 
31
+ def self.descriptor = self._descriptor
32
+
25
33
  def self.reset
26
34
  self.webhook_response = Webhookdb::WebhookResponse.ok
27
35
  self.upsert_has_deps = false
@@ -30,6 +38,11 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
30
38
  self.process_webhooks_synchronously = nil
31
39
  self.obfuscate_headers_for_logging = []
32
40
  self.requires_sequence = false
41
+ self.descendants&.each do |d|
42
+ d.reset if d.respond_to?(:reset)
43
+ end
44
+ self.extra_index_specs = nil
45
+ self.descendants&.each(&:reset)
33
46
  end
34
47
 
35
48
  def self.stub_backfill_request(items, status: 200)
@@ -121,6 +134,8 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
121
134
  return self.class.requires_sequence
122
135
  end
123
136
 
137
+ def _extra_index_specs = super + (self.class.extra_index_specs || [])
138
+
124
139
  def dispatch_request_to(request)
125
140
  return self.class.dispatch_request_to_hook.call(request) if self.class.dispatch_request_to_hook
126
141
  return super
@@ -138,16 +153,7 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
138
153
  end
139
154
 
140
155
  class Webhookdb::Replicator::FakeWithEnrichments < Webhookdb::Replicator::Fake
141
- def self.descriptor
142
- return Webhookdb::Replicator::Descriptor.new(
143
- name: "fake_with_enrichments_v1",
144
- ctor: ->(sint) { Webhookdb::Replicator::FakeWithEnrichments.new(sint) },
145
- feature_roles: ["internal"],
146
- resource_name_singular: "Enriched Fake",
147
- supports_webhooks: true,
148
- supports_backfill: true,
149
- )
150
- end
156
+ def self.descriptor = self._descriptor(supports_webhooks: true, supports_backfill: true)
151
157
 
152
158
  def _denormalized_columns
153
159
  return super << Webhookdb::Replicator::Column.new(:extra, TEXT, from_enrichment: true)
@@ -166,17 +172,7 @@ end
166
172
  class Webhookdb::Replicator::FakeDependent < Webhookdb::Replicator::Fake
167
173
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
168
174
 
169
- def self.descriptor
170
- return Webhookdb::Replicator::Descriptor.new(
171
- name: "fake_dependent_v1",
172
- ctor: ->(sint) { Webhookdb::Replicator::FakeDependent.new(sint) },
173
- feature_roles: ["internal"],
174
- resource_name_singular: "FakeDependent",
175
- dependency_descriptor: Webhookdb::Replicator::Fake.descriptor,
176
- supports_webhooks: true,
177
- supports_backfill: true,
178
- )
179
- end
175
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::Fake.descriptor)
180
176
 
181
177
  def on_dependency_webhook_upsert(replicator, payload, changed:)
182
178
  self.class.on_dependency_webhook_upsert_callback&.call(replicator, payload, changed:)
@@ -194,17 +190,7 @@ end
194
190
  class Webhookdb::Replicator::FakeDependentDependent < Webhookdb::Replicator::Fake
195
191
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
196
192
 
197
- def self.descriptor
198
- return Webhookdb::Replicator::Descriptor.new(
199
- name: "fake_dependent_dependent_v1",
200
- ctor: ->(sint) { Webhookdb::Replicator::FakeDependentDependent.new(sint) },
201
- feature_roles: ["internal"],
202
- resource_name_singular: "FakeDependentDependent",
203
- dependency_descriptor: Webhookdb::Replicator::FakeDependent.descriptor,
204
- supports_webhooks: true,
205
- supports_backfill: true,
206
- )
207
- end
193
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::FakeDependent.descriptor)
208
194
 
209
195
  def on_dependency_webhook_upsert(replicator, payload, changed:)
210
196
  self.class.on_dependency_webhook_upsert_callback&.call(replicator, payload, changed:)
@@ -222,16 +208,7 @@ end
222
208
  class Webhookdb::Replicator::FakeEnqueueBackfillOnCreate < Webhookdb::Replicator::Fake
223
209
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
224
210
 
225
- def self.descriptor
226
- return Webhookdb::Replicator::Descriptor.new(
227
- name: "fake_enqueue_backfill_on_create_v1",
228
- ctor: ->(sint) { Webhookdb::Replicator::FakeEnqueueBackfillOnCreate.new(sint) },
229
- feature_roles: ["internal"],
230
- resource_name_singular: "FakeEnqueueBackfillOnCreate",
231
- supports_webhooks: true,
232
- supports_backfill: true,
233
- )
234
- end
211
+ def self.descriptor = self._descriptor
235
212
 
236
213
  def calculate_backfill_state_machine
237
214
  # To mimic situations where it is possible to enqueue a backfill job on create, this backfill machine does
@@ -246,16 +223,7 @@ class Webhookdb::Replicator::FakeEnqueueBackfillOnCreate < Webhookdb::Replicator
246
223
  end
247
224
 
248
225
  class Webhookdb::Replicator::FakeWebhooksOnly < Webhookdb::Replicator::Fake
249
- def self.descriptor
250
- return Webhookdb::Replicator::Descriptor.new(
251
- name: "fake_webhooks_only_v1",
252
- ctor: ->(sint) { Webhookdb::Replicator::FakeWebhooksOnly.new(sint) },
253
- feature_roles: ["internal"],
254
- resource_name_singular: "Fake Webhooks Only (No Backfill)",
255
- supports_webhooks: true,
256
- supports_backfill: false,
257
- )
258
- end
226
+ def self.descriptor = self._descriptor(supports_backfill: false)
259
227
 
260
228
  def documentation_url = "https://abc.xyz"
261
229
 
@@ -263,31 +231,13 @@ class Webhookdb::Replicator::FakeWebhooksOnly < Webhookdb::Replicator::Fake
263
231
  end
264
232
 
265
233
  class Webhookdb::Replicator::FakeBackfillOnly < Webhookdb::Replicator::Fake
266
- def self.descriptor
267
- return Webhookdb::Replicator::Descriptor.new(
268
- name: "fake_backfill_only_v1",
269
- ctor: ->(sint) { Webhookdb::Replicator::FakeBackfillOnly.new(sint) },
270
- feature_roles: ["internal"],
271
- resource_name_singular: "Fake Backfill Only (No Webhooks)",
272
- supports_webhooks: false,
273
- supports_backfill: true,
274
- )
275
- end
234
+ def self.descriptor = self._descriptor(supports_webhooks: false)
276
235
 
277
236
  def calculate_webhook_state_machine = raise NotImplementedError
278
237
  end
279
238
 
280
239
  class Webhookdb::Replicator::FakeBackfillWithCriteria < Webhookdb::Replicator::Fake
281
- def self.descriptor
282
- return Webhookdb::Replicator::Descriptor.new(
283
- name: "fake_backfill_with_criteria_v1",
284
- ctor: ->(sint) { Webhookdb::Replicator::FakeBackfillWithCriteria.new(sint) },
285
- feature_roles: ["internal"],
286
- resource_name_singular: "Fake Backfill with Criteria",
287
- dependency_descriptor: Webhookdb::Replicator::Fake,
288
- supports_backfill: true,
289
- )
290
- end
240
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::Fake)
291
241
 
292
242
  def _denormalized_columns
293
243
  return super << Webhookdb::Replicator::Column.new(:backfill_kwargs, OBJECT, optional: true)
@@ -316,16 +266,7 @@ class Webhookdb::Replicator::FakeBackfillWithCriteria < Webhookdb::Replicator::F
316
266
  end
317
267
 
318
268
  class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fake
319
- def self.descriptor
320
- return Webhookdb::Replicator::Descriptor.new(
321
- name: "fake_exhaustive_converter_v1",
322
- ctor: ->(sint) { Webhookdb::Replicator::FakeExhaustiveConverter.new(sint) },
323
- feature_roles: ["internal"],
324
- resource_name_singular: "Fake with all converters",
325
- supports_webhooks: true,
326
- supports_backfill: false,
327
- )
328
- end
269
+ def self.descriptor = self._descriptor(supports_backfill: false)
329
270
 
330
271
  def requires_sequence? = true
331
272
 
@@ -409,6 +350,12 @@ class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fa
409
350
  data_key: "my_id",
410
351
  backfill_expr: "hi there",
411
352
  ),
353
+ Webhookdb::Replicator::Column.new(
354
+ :using_null_backfill_expr,
355
+ TEXT,
356
+ data_key: "my_id",
357
+ backfill_expr: Sequel[nil],
358
+ ),
412
359
  Webhookdb::Replicator::Column.new(
413
360
  :using_backfill_statement,
414
361
  TEXT,
@@ -450,3 +397,74 @@ class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fa
450
397
  return cols
451
398
  end
452
399
  end
400
+
401
+ class Webhookdb::Replicator::FakeStaleRow < Webhookdb::Replicator::Fake
402
+ def self.descriptor = self._descriptor
403
+
404
+ def _denormalized_columns
405
+ return [
406
+ Webhookdb::Replicator::Column.new(:at, TIMESTAMP, index: true),
407
+ Webhookdb::Replicator::Column.new(:textcol, TEXT),
408
+ ]
409
+ end
410
+
411
+ class StaleRowDeleter < Webhookdb::Replicator::BaseStaleRowDeleter
412
+ def stale_at = 5.days
413
+ def lookback_window = 5.days
414
+ def updated_at_column = :at
415
+ def stale_condition = {textcol: "cancelled"}
416
+ def chunk_size = 10
417
+ end
418
+
419
+ def stale_row_deleter = StaleRowDeleter.new(self)
420
+ end
421
+
422
+ class Webhookdb::Replicator::FakeStaleRowPartitioned < Webhookdb::Replicator::FakeStaleRow
423
+ include Webhookdb::Replicator::PartitionableMixin
424
+
425
+ def self.descriptor = self._descriptor
426
+
427
+ def partition_method = Webhookdb::DBAdapter::Partitioning::HASH
428
+ def partition_column_name = :textcol
429
+ def partition_value(r) = r.fetch("textcol")
430
+ end
431
+
432
+ class Webhookdb::Replicator::FakeWithWatchChannel < Webhookdb::Replicator::Fake
433
+ singleton_attr_accessor :renew_calls
434
+
435
+ def self.reset
436
+ self.renew_calls = []
437
+ end
438
+
439
+ def self.descriptor = self._descriptor
440
+
441
+ def renew_watch_channel(row_pk:, expiring_before:)
442
+ self.class.renew_calls << {row_pk:, expiring_before:}
443
+ end
444
+ end
445
+
446
+ class Webhookdb::Replicator::FakeHashPartition < Webhookdb::Replicator::Fake
447
+ def self.descriptor = self._descriptor
448
+
449
+ include Webhookdb::Replicator::PartitionableMixin
450
+
451
+ def _denormalized_columns
452
+ d = super
453
+ d << Webhookdb::Replicator::Column.new(:hashkey, INTEGER, optional: true)
454
+ return d
455
+ end
456
+
457
+ def partition_method = Webhookdb::DBAdapter::Partitioning::HASH
458
+ def partition_column_name = :hashkey
459
+ def partition_value(resource) = self._str2inthash(resource.fetch("my_id"))
460
+ end
461
+
462
+ class Webhookdb::Replicator::FakeRangePartition < Webhookdb::Replicator::Fake
463
+ def self.descriptor = self._descriptor
464
+
465
+ include Webhookdb::Replicator::PartitionableMixin
466
+
467
+ def partition_method = Webhookdb::DBAdapter::Partitioning::RANGE
468
+ def partition_column_name = :at
469
+ def partition_value(resource) = resource.fetch("at")
470
+ end
@@ -4,6 +4,7 @@ require "jwt"
4
4
 
5
5
  require "webhookdb/messages/error_signalwire_send_sms"
6
6
  require "webhookdb/replicator/front_v1_mixin"
7
+ require "webhookdb/jobs/front_signalwire_message_channel_sync_inbound"
7
8
 
8
9
  # Front has a system of 'channels' but it is a challenge to use.
9
10
  # This replicator leverages WebhookDB (and our existing Front app)
@@ -154,8 +155,8 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
154
155
  when "message", "message_autoreply"
155
156
  return {
156
157
  type: "success",
157
- external_id: upserted.fetch(:external_id),
158
- external_conversation_id: upserted.fetch(:external_conversation_id),
158
+ external_id: upserted.map { |r| r.fetch(:external_id) }.join(","),
159
+ external_conversation_id: upserted.map { |r| r.fetch(:external_conversation_id) }.join(","),
159
160
  }.to_json
160
161
  else
161
162
  return "{}"
@@ -191,35 +192,52 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
191
192
  "#{replied_to_id}_autoreply"
192
193
  end
193
194
  resource["front_message_id"] = mid
194
- # Use the Front ID to identify this outbound message.
195
- resource["external_id"] = mid
196
195
  resource["direction"] = "outbound"
197
196
  resource["body"] = payload.fetch("text")
198
197
  resource["sender"] = self.support_phone
199
- resource["recipient"] = self._front_recipient_phone(payload)
200
- # All messages get the same conversation with SMS/chat, unlike email.
201
- resource["external_conversation_id"] = resource["recipient"]
202
- return resource, nil
198
+ resources = self._front_recipient_phones(payload).map do |recipient|
199
+ r = resource.dup
200
+ r["recipient"] = recipient
201
+ # The same message can go to multiple recipients, but we want to treat them as separate conversations.
202
+ # That is, we CANNOT use signalwire/front to do 'group chats' since we don't want to
203
+ # allow one user to send a message that is sent to other users (would be a spam vector).
204
+ r["external_id"] = "#{mid}-#{recipient}"
205
+ # Thread this message into the recipient's specific conversation, unlike email.
206
+ r["external_conversation_id"] = recipient
207
+ r
208
+ end
209
+ return resources, nil
203
210
  end
204
211
 
205
- def _front_recipient_phone(payload)
206
- recipient = payload["recipients"].find { |r| r.fetch("role") == "to" }
207
- raise Webhookdb::InvariantViolation, "no recipient found in #{payload}" if recipient.nil?
208
- return self.format_phone(recipient.fetch("handle"))
212
+ def _front_recipient_phones(payload)
213
+ recipients = payload["recipients"].select { |r| r.fetch("role") == "to" }
214
+ raise Webhookdb::InvariantViolation, "no recipient found in #{payload}" if recipients.empty?
215
+ return recipients.map { |r| self.format_phone(r.fetch("handle")) }
209
216
  end
210
217
 
211
- def on_dependency_webhook_upsert(_replicator, payload, changed:)
218
+ def on_dependency_webhook_upsert(_sw_replicator, sw_payload, changed:)
212
219
  return unless changed
213
- return unless payload.fetch(:direction) == "inbound"
214
- return unless payload.fetch(:to) == self.support_phone
215
- body = JSON.parse(payload.fetch(:data))
220
+
221
+ # If the signalwire message is failed, update the Front convo with a notification that the send failed
222
+ failed_notifier_cutoff = Time.now - 4.days
223
+ signalwire_send_failed = sw_payload.fetch(:date_updated) > failed_notifier_cutoff &&
224
+ ["failed", "undelivered"].include?(sw_payload.fetch(:status)) &&
225
+ sw_payload.fetch(:from) == self.support_phone
226
+ self.alert_async_failed_signalwire_send(sw_payload) if signalwire_send_failed
227
+
228
+ # If a message has come in from a user, insert a row so it'll be imported into Front
229
+ signalwire_payload_inbound_to_support = sw_payload.fetch(:direction) == "inbound" &&
230
+ sw_payload.fetch(:to) == self.support_phone
231
+ return unless signalwire_payload_inbound_to_support
232
+
233
+ body = JSON.parse(sw_payload.fetch(:data))
216
234
  body.merge!(
217
- "external_id" => payload.fetch(:signalwire_id),
218
- "signalwire_sid" => payload.fetch(:signalwire_id),
235
+ "external_id" => sw_payload.fetch(:signalwire_id),
236
+ "signalwire_sid" => sw_payload.fetch(:signalwire_id),
219
237
  "direction" => "inbound",
220
- "sender" => payload.fetch(:from),
238
+ "sender" => sw_payload.fetch(:from),
221
239
  "recipient" => self.support_phone,
222
- "external_conversation_id" => payload.fetch(:from),
240
+ "external_conversation_id" => sw_payload.fetch(:from),
223
241
  )
224
242
  self.upsert_webhook_body(body)
225
243
  end
@@ -230,6 +248,59 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
230
248
  Webhookdb::BackfillJob.create_recursive(service_integration: self.service_integration, incremental: true).enqueue
231
249
  end
232
250
 
251
+ # Send alerts for any undelivered or failed messages.
252
+ # The (outbound) message is already created in Front, but if the Signalwire message fails to send,
253
+ # we need to import a new message into Front as a reply explaining why the message failed to send.
254
+ def alert_async_failed_signalwire_send(sw_row)
255
+ idempotency_key = "fsmca-swfail-#{sw_row.fetch(:signalwire_id)}"
256
+ idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
257
+ idempotency.execute do
258
+ # The 'sender' of this message is who the failed message is sent **to**
259
+ sender = sw_row.fetch(:to)
260
+ data = JSON.parse(sw_row.fetch(:data))
261
+ external_id = sw_row.fetch(:signalwire_id)
262
+ external_conversation_id = sender
263
+ trunc_body = data.fetch("body", "")[..25]
264
+ body = "SMS failed to send. Error (#{data['error_code'] || '-'}): #{data['error_message'] || '-'}\n#{trunc_body}"
265
+ kwargs = {sender:, delivered_at: Time.now.to_i, body:, external_id:, external_conversation_id:}
266
+ # The call to Front MUST be done in a job, since if it fails, we would not be able to retry.
267
+ # The code is called after the signalwire payload is upserted and changes;
268
+ # but if this fails, the row won't change again in the future,
269
+ # so this code wouldn't be called again.
270
+ # This is a general problem and should probably have a general solution,
271
+ # but because of the external call, it is important to guard against it.
272
+ Webhookdb::Jobs::FrontSignalwireMessageChannelSyncInbound.perform_async(
273
+ self.service_integration.id, kwargs.as_json,
274
+ )
275
+ end
276
+ end
277
+
278
+ def sync_front_inbound_message(sender:, delivered_at:, body:, external_id:, external_conversation_id:)
279
+ body = {
280
+ sender: {handle: sender},
281
+ body:,
282
+ delivered_at:,
283
+ metadata: {external_id:, external_conversation_id:},
284
+ }
285
+ token = JWT.encode(
286
+ {
287
+ iss: Webhookdb::Front.signalwire_channel_app_id,
288
+ jti: Webhookdb::Front.channel_jwt_jti,
289
+ sub: self.front_channel_id,
290
+ exp: 10.seconds.from_now.to_i,
291
+ },
292
+ Webhookdb::Front.signalwire_channel_app_secret,
293
+ )
294
+ resp = Webhookdb::Http.post(
295
+ "https://api2.frontapp.com/channels/#{self.front_channel_id}/inbound_messages",
296
+ body,
297
+ headers: {"Authorization" => "Bearer #{token}"},
298
+ timeout: Webhookdb::Front.http_timeout,
299
+ logger: self.logger,
300
+ )
301
+ return resp.parsed_response
302
+ end
303
+
233
304
  def _backfillers = [Backfiller.new(self)]
234
305
 
235
306
  class Backfiller < Webhookdb::Backfiller
@@ -239,35 +310,44 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
239
310
  @signalwire_sint = replicator.service_integration.depends_on
240
311
  end
241
312
 
242
- def handle_item(item)
243
- front_id = item.fetch(:front_message_id)
244
- sw_id = item.fetch(:signalwire_sid)
313
+ def handle_item(db_row)
314
+ front_id = db_row.fetch(:front_message_id)
315
+ sw_id = db_row.fetch(:signalwire_sid)
316
+ # This is sort of gross- we get the db row here, and need to re-update it with certain fields
317
+ # as a result of the signalwire or front sync. To do that, we need to run the upsert on 'data',
318
+ # but what's in 'data' is incomplete. So we use the db row to form a more fully complete 'data'.
319
+ upserting_data = db_row.dup
320
+ # Remove the columns that don't belong in 'data'
321
+ upserting_data.delete(:pk)
322
+ upserting_data.delete(:row_updated_at)
323
+ # Splat the 'data' column into the row so it all gets put back into 'data'
324
+ upserting_data.merge!(**upserting_data.delete(:data))
245
325
  if (front_id && sw_id) || (!front_id && !sw_id)
246
- msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{item}"
326
+ msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{db_row}"
247
327
  raise Webhookdb::InvariantViolation, msg
248
328
  end
249
- sender = @replicator.format_phone(item.fetch(:sender))
250
- recipient = @replicator.format_phone(item.fetch(:recipient))
251
- body = item.fetch(:body)
252
- idempotency_key = "fsmca-fims-#{item.fetch(:external_id)}"
329
+ sender = @replicator.format_phone(db_row.fetch(:sender))
330
+ recipient = @replicator.format_phone(db_row.fetch(:recipient))
331
+ body = db_row.fetch(:body)
332
+ idempotency_key = "fsmca-fims-#{db_row.fetch(:external_id)}"
253
333
  idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
254
334
  if front_id.nil?
255
- texted_at = Time.parse(item.fetch(:data).fetch("date_created"))
335
+ texted_at = Time.parse(db_row.fetch(:data).fetch("date_created"))
256
336
  if texted_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
257
337
  # Do not sync old rows, just mark them synced
258
- item[:front_message_id] = "skipped_due_to_age"
338
+ upserting_data[:front_message_id] = "skipped_due_to_age"
259
339
  else
260
340
  # sync the message into Front
261
341
  front_response_body = idempotency.execute do
262
- self._sync_front_inbound(sender:, texted_at:, item:, body:)
342
+ self._sync_front_inbound(sender:, texted_at:, db_row:, body:)
263
343
  end
264
- item[:front_message_id] = front_response_body.fetch("message_uid")
344
+ upserting_data[:front_message_id] = front_response_body.fetch("message_uid")
265
345
  end
266
346
  else
267
- messaged_at = Time.at(item.fetch(:data).fetch("payload").fetch("created_at"))
347
+ messaged_at = Time.at(db_row.fetch(:data).fetch("payload").fetch("created_at"))
268
348
  if messaged_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
269
349
  # Do not sync old rows, just mark them synced
270
- item[:signalwire_sid] = "skipped_due_to_age"
350
+ upserting_data[:signalwire_sid] = "skipped_due_to_age"
271
351
  else
272
352
  # send the SMS via signalwire
273
353
  signalwire_resp = _send_sms(
@@ -276,10 +356,10 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
276
356
  to: recipient,
277
357
  body:,
278
358
  )
279
- item[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
359
+ upserting_data[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
280
360
  end
281
361
  end
282
- @replicator.upsert_webhook_body(item.deep_stringify_keys)
362
+ @replicator.upsert_webhook_body(upserting_data.deep_stringify_keys)
283
363
  end
284
364
 
285
365
  def _send_sms(idempotency, from:, to:, body:)
@@ -310,6 +390,14 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
310
390
  # https://developer.signalwire.com/guides/how-to-troubleshoot-common-messaging-issues
311
391
  raise e if code.nil?
312
392
 
393
+ # Error handling note as of Jan 2025:
394
+ # We are choosing to handle synchronous 'send sms' errors through the org alert system,
395
+ # which will tell developers (not support agents) about the failure.
396
+ # This is because, if this send fails, it will be retried later.
397
+ # For example, if we sent a bulk message to 1000 customers,
398
+ # and Signalwire was down and failed 500 sends, we would just retry the 500 sends.
399
+ # We do NOT want to update the 500 failed conversations, and force support agents
400
+ # to deal with the fallout of retrying a send only to those 500 people.
313
401
  message = Webhookdb::Messages::ErrorSignalwireSendSms.new(
314
402
  @replicator.service_integration,
315
403
  response_status:,
@@ -321,33 +409,15 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
321
409
  return nil
322
410
  end
323
411
 
324
- def _sync_front_inbound(sender:, texted_at:, item:, body:)
325
- body = {
326
- sender: {handle: sender},
327
- body: body || "<no body>",
412
+ def _sync_front_inbound(sender:, texted_at:, db_row:, body:)
413
+ body ||= "<no body>"
414
+ return @replicator.sync_front_inbound_message(
415
+ sender:,
328
416
  delivered_at: texted_at.to_i,
329
- metadata: {
330
- external_id: item.fetch(:external_id),
331
- external_conversation_id: item.fetch(:external_conversation_id),
332
- },
333
- }
334
- token = JWT.encode(
335
- {
336
- iss: Webhookdb::Front.signalwire_channel_app_id,
337
- jti: Webhookdb::Front.channel_jwt_jti,
338
- sub: @replicator.front_channel_id,
339
- exp: 10.seconds.from_now.to_i,
340
- },
341
- Webhookdb::Front.signalwire_channel_app_secret,
342
- )
343
- resp = Webhookdb::Http.post(
344
- "https://api2.frontapp.com/channels/#{@replicator.front_channel_id}/inbound_messages",
345
- body,
346
- headers: {"Authorization" => "Bearer #{token}"},
347
- timeout: Webhookdb::Front.http_timeout,
348
- logger: @replicator.logger,
417
+ body:,
418
+ external_id: db_row.fetch(:external_id),
419
+ external_conversation_id: db_row.fetch(:external_conversation_id),
349
420
  )
350
- resp.parsed_response
351
421
  end
352
422
 
353
423
  def fetch_backfill_page(*)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "webhookdb/github"
4
+ require "webhookdb/messages/error_generic_backfill"
4
5
 
5
6
  # Mixin for repo-specific resources like issues and pull requests.
6
7
  module Webhookdb::Replicator::GithubRepoV1Mixin
@@ -236,6 +237,22 @@ Then click 'Generate token'.)
236
237
  return data
237
238
  end
238
239
 
240
+ def on_backfill_error(be)
241
+ e = Webhookdb::Errors.find_cause(be) do |ex|
242
+ next true if ex.is_a?(Webhookdb::Http::Error) && ex.status == 401
243
+ end
244
+ return unless e
245
+ message = Webhookdb::Messages::ErrorGenericBackfill.new(
246
+ self.service_integration,
247
+ response_status: e.status,
248
+ response_body: e.body,
249
+ request_url: e.uri.to_s,
250
+ request_method: e.http_method,
251
+ )
252
+ self.service_integration.organization.alerting.dispatch_alert(message)
253
+ return true
254
+ end
255
+
239
256
  def _prepare_for_insert(resource, event, request, enrichment)
240
257
  # if enrichment is not nil, it's the detailed resource.
241
258
  # See _mixin_fetch_resource_if_field_missing