webhookdb 1.0.2 → 1.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/data/messages/web/install-customer-login.liquid +1 -1
  3. data/data/messages/web/install-success.liquid +2 -2
  4. data/db/migrations/038_webhookdb_api_key.rb +13 -0
  5. data/db/migrations/039_saved_query.rb +17 -0
  6. data/lib/webhookdb/api/db.rb +2 -19
  7. data/lib/webhookdb/api/helpers.rb +27 -0
  8. data/lib/webhookdb/api/install.rb +23 -1
  9. data/lib/webhookdb/api/saved_queries.rb +219 -0
  10. data/lib/webhookdb/api/service_integrations.rb +17 -12
  11. data/lib/webhookdb/api/sync_targets.rb +2 -6
  12. data/lib/webhookdb/api/system.rb +13 -2
  13. data/lib/webhookdb/api/webhook_subscriptions.rb +12 -18
  14. data/lib/webhookdb/api.rb +11 -2
  15. data/lib/webhookdb/apps.rb +2 -0
  16. data/lib/webhookdb/email_octopus.rb +2 -0
  17. data/lib/webhookdb/envfixer.rb +29 -0
  18. data/lib/webhookdb/fixtures/saved_queries.rb +27 -0
  19. data/lib/webhookdb/fixtures/service_integrations.rb +4 -0
  20. data/lib/webhookdb/front.rb +23 -11
  21. data/lib/webhookdb/google_calendar.rb +6 -0
  22. data/lib/webhookdb/icalendar.rb +6 -0
  23. data/lib/webhookdb/idempotency.rb +94 -33
  24. data/lib/webhookdb/jobs/backfill.rb +24 -5
  25. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +7 -1
  26. data/lib/webhookdb/jobs/prepare_database_connections.rb +2 -2
  27. data/lib/webhookdb/jobs/scheduled_backfills.rb +19 -13
  28. data/lib/webhookdb/oauth/{front.rb → front_provider.rb} +21 -4
  29. data/lib/webhookdb/oauth/{intercom.rb → intercom_provider.rb} +1 -1
  30. data/lib/webhookdb/oauth.rb +8 -7
  31. data/lib/webhookdb/organization/alerting.rb +11 -0
  32. data/lib/webhookdb/organization.rb +8 -2
  33. data/lib/webhookdb/postgres/model_utilities.rb +19 -0
  34. data/lib/webhookdb/postgres.rb +3 -0
  35. data/lib/webhookdb/replicator/column.rb +9 -1
  36. data/lib/webhookdb/replicator/front_conversation_v1.rb +5 -1
  37. data/lib/webhookdb/replicator/front_marketplace_root_v1.rb +2 -5
  38. data/lib/webhookdb/replicator/front_message_v1.rb +5 -1
  39. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +325 -0
  40. data/lib/webhookdb/replicator/front_v1_mixin.rb +9 -1
  41. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +6 -3
  42. data/lib/webhookdb/replicator/signalwire_message_v1.rb +28 -14
  43. data/lib/webhookdb/replicator/twilio_sms_v1.rb +3 -1
  44. data/lib/webhookdb/saved_query.rb +28 -0
  45. data/lib/webhookdb/service_integration.rb +36 -3
  46. data/lib/webhookdb/signalwire.rb +40 -0
  47. data/lib/webhookdb/spec_helpers/citest.rb +18 -9
  48. data/lib/webhookdb/spec_helpers/postgres.rb +9 -0
  49. data/lib/webhookdb/spec_helpers/service.rb +5 -0
  50. data/lib/webhookdb/spec_helpers/shared_examples_for_columns.rb +25 -13
  51. data/lib/webhookdb/spec_helpers/whdb.rb +7 -0
  52. data/lib/webhookdb/sync_target.rb +1 -1
  53. data/lib/webhookdb/tasks/specs.rb +4 -2
  54. data/lib/webhookdb/version.rb +1 -1
  55. data/lib/webhookdb.rb +24 -26
  56. metadata +39 -4
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faker"
4
+
5
+ require "webhookdb"
6
+ require "webhookdb/fixtures"
7
+
8
+ module Webhookdb::Fixtures::SavedQueries
9
+ extend Webhookdb::Fixtures
10
+
11
+ fixtured_class Webhookdb::SavedQuery
12
+
13
+ base :saved_query do
14
+ self.description ||= Faker::Lorem.sentence
15
+ self.sql ||= "SELECT * FROM mytable"
16
+ end
17
+
18
+ before_saving do |instance|
19
+ instance.organization ||= Webhookdb::Fixtures.organization.create
20
+ instance
21
+ end
22
+
23
+ decorator :created_by do |c={}|
24
+ c = Webhookdb::Fixtures.customer.create(c) unless c.is_a?(Webhookdb::Customer)
25
+ self.created_by = c
26
+ end
27
+ end
@@ -39,4 +39,8 @@ module Webhookdb::Fixtures::ServiceIntegrations
39
39
  self.backfill_key = "fake_bfkey"
40
40
  self.backfill_secret = "fake_bfsecret"
41
41
  end
42
+
43
+ decorator :with_api_key do
44
+ self.webhookdb_api_key ||= self.new_api_key
45
+ end
42
46
  end
@@ -6,34 +6,44 @@ require "appydays/loggable"
6
6
  module Webhookdb::Front
7
7
  include Appydays::Configurable
8
8
 
9
+ CHANNEL_EVENT_TYPES = Set.new(["authorization", "delete", "message", "message_autoreply", "message_imported"])
10
+
9
11
  configurable(:front) do
10
- # The api secret is used for webhook verification, the client id and secret are used for OAuth
11
- setting :api_secret, "front_api_secret"
12
+ setting :http_timeout, 30
13
+
14
+ # WebhookDB App: App Secret from Basic Information tab in Front UI.
15
+ setting :app_secret, "front_api_secret", key: ["FRONT_APP_SECRET", "FRONT_API_SECRET"]
16
+ # WebhookDB App: Client ID from OAuth tab in Front UI.
12
17
  setting :client_id, "front_client_id"
18
+ # WebhookDB App: Client Secret from OAuth tab in Front UI.
13
19
  setting :client_secret, "front_client_secret"
14
- setting :http_timeout, 30
15
- end
16
20
 
17
- def self.oauth_callback_url = Webhookdb.api_url + "/v1/install/front/callback"
21
+ setting :signalwire_channel_app_id, "front_swchan_app_id"
22
+ setting :signalwire_channel_app_secret, "front_swchan_app_secret"
23
+ setting :signalwire_channel_client_id, "front_swchan_client_id"
24
+ setting :signalwire_channel_client_secret, "front_swchan_client_secret"
25
+
26
+ setting :channel_sync_refreshness_cutoff, 48.hours.to_i
27
+ end
18
28
 
19
- def self.verify_signature(request)
29
+ def self.verify_signature(request, secret)
20
30
  request.body.rewind
21
31
  body = request.body.read
22
32
  base_string = "#{request.env['HTTP_X_FRONT_REQUEST_TIMESTAMP']}:#{body}"
23
- calculated_signature = OpenSSL::HMAC.base64digest(OpenSSL::Digest.new("sha256"), self.api_secret, base_string)
33
+ calculated_signature = OpenSSL::HMAC.base64digest(OpenSSL::Digest.new("sha256"), secret, base_string)
24
34
  return calculated_signature == request.env["HTTP_X_FRONT_SIGNATURE"]
25
35
  end
26
36
 
27
- def self.webhook_response(request)
37
+ def self.webhook_response(request, secret)
28
38
  return Webhookdb::WebhookResponse.error("missing signature") unless request.env["HTTP_X_FRONT_SIGNATURE"]
29
39
 
30
- from_front = Webhookdb::Front.verify_signature(request)
40
+ from_front = self.verify_signature(request, secret)
31
41
  return Webhookdb::WebhookResponse.ok(status: 200) if from_front
32
42
  return Webhookdb::WebhookResponse.error("invalid signature")
33
43
  end
34
44
 
35
- def self.initial_verification_request_response(request)
36
- from_front = self.verify_signature(request)
45
+ def self.initial_verification_request_response(request, secret)
46
+ from_front = self.verify_signature(request, secret)
37
47
  if from_front
38
48
  return Webhookdb::WebhookResponse.ok(
39
49
  json: {challenge: request.env["HTTP_X_FRONT_CHALLENGE"]},
@@ -46,4 +56,6 @@ module Webhookdb::Front
46
56
  def self.auth_headers(token)
47
57
  return {"Authorization" => "Bearer #{token}"}
48
58
  end
59
+
60
+ def self.channel_jwt_jti = SecureRandom.hex(4)
49
61
  end
@@ -9,6 +9,12 @@ module Webhookdb::GoogleCalendar
9
9
  # How many calendars/events should we fetch in a single page?
10
10
  # Higher uses slightly more memory but fewer API calls.
11
11
  # Max of 2500.
12
+ #
13
+ # **NOTE:** Changing this in production will
14
+ # INVALIDATE EXISTING RESOURCES CAUSING A FULL RESYNC.
15
+ # You should, in general, avoid modifying this number once there is
16
+ # much Google Calendar data stored. Instead, page sizes will be automatically reduced
17
+ # as requests time out.
12
18
  setting :list_page_size, 2000
13
19
  # How many rows should we upsert at a time?
14
20
  # Higher is fewer upserts, but can create very large SQL strings,
@@ -13,5 +13,11 @@ module Webhookdb::Icalendar
13
13
  # Do not store events older then this when syncing recurring events.
14
14
  # Many icalendar feeds are misconfigured and this prevents enumerating 2000+ years of recurrence.
15
15
  setting :oldest_recurring_event, "1990-01-01", convert: ->(s) { Date.parse(s) }
16
+ # Sync icalendar calendars only this often.
17
+ # Most services only update every day or so. Assume it takes 5s to sync each feed (request, parse, upsert).
18
+ # If you have 10,000 feeds, that is 50,000 seconds, or almost 14 hours of processing time,
19
+ # or two threads for 7 hours. The resyncs are spread out across the sync period
20
+ # (ie, no thundering herd every 8 hours), but it is still a good idea to sync as infrequently as possible.
21
+ setting :sync_period_hours, 6
16
22
  end
17
23
  end
@@ -26,51 +26,112 @@ require "webhookdb/postgres/model"
26
26
  # usually using the :no_transaction_check spec metadata.
27
27
  #
28
28
  class Webhookdb::Idempotency < Webhookdb::Postgres::Model(:idempotencies)
29
- extend Webhookdb::MethodUtilities
30
-
31
29
  NOOP = :skipped
32
30
 
33
- # Skip the transaction check. Useful in unit tests. See class docs for details.
34
- singleton_predicate_accessor :skip_transaction_check
31
+ class << self
32
+ # Skip the transaction check. Useful in unit tests. See class docs for details.
33
+ attr_accessor :skip_transaction_check
35
34
 
36
- def self.once_ever
37
- idem = self.new
38
- idem.__once_ever = true
39
- return idem
40
- end
35
+ def skip_transaction_check? = self.skip_transaction_check
41
36
 
42
- def self.every(interval)
43
- idem = self.new
44
- idem.__every = interval
45
- return idem
46
- end
37
+ # @return [Builder]
38
+ def once_ever
39
+ b = self::Builder.new
40
+ b._once_ever = true
41
+ return b
42
+ end
47
43
 
48
- attr_accessor :__every, :__once_ever
44
+ # @return [Builder]
45
+ def every(interval)
46
+ b = self::Builder.new
47
+ b._every = interval
48
+ return b
49
+ end
49
50
 
50
- def under_key(key, &block)
51
- self.key = key
52
- return self.execute(&block) if block
53
- return self
51
+ def separate_connection
52
+ @connection ||= Sequel.connect(
53
+ uri,
54
+ logger: self.logger,
55
+ extensions: [
56
+ :connection_validator,
57
+ :pg_json, # Must have this to mirror the main model DB
58
+ ],
59
+ **Webhookdb::Dbutil.configured_connection_options,
60
+ )
61
+ return @connection
62
+ end
54
63
  end
55
64
 
56
- def execute
57
- Webhookdb::Postgres.check_transaction(
58
- self.db,
59
- "Cannot use idempotency while already in a transaction, since side effects may not be idempotent",
60
- )
65
+ class Builder
66
+ attr_accessor :_every, :_once_ever, :_stored, :_sepconn, :_key
67
+
68
+ # If set, the result of block is stored as JSON,
69
+ # and returned when an idempotent call is made.
70
+ # The JSON value (as_json) is returned from the block in all cases.
71
+ # @return [Builder]
72
+ def stored
73
+ self._stored = true
74
+ return self
75
+ end
61
76
 
62
- self.class.dataset.insert_conflict.insert(key: self.key)
63
- self.db.transaction do
64
- idem = Webhookdb::Idempotency[key: self.key].lock!
65
- if idem.last_run.nil?
77
+ # Run the idempotency on a separate connection.
78
+ # Allows use of idempotency within an existing transaction block,
79
+ # which is normally not allowed. Usually should be used with #stored,
80
+ # since otherwise the result of the idempotency will be lost.
81
+ #
82
+ # NOTE: When calling code with using_seperate_connection,
83
+ # you may want to use the spec metadata `truncate: Webhookdb::Idempotency`
84
+ # since the row won't be covered by the spec's transaction.
85
+ #
86
+ # @return [Builder]
87
+ def using_seperate_connection
88
+ self._sepconn = true
89
+ return self
90
+ end
91
+
92
+ # @return [Builder]
93
+ def under_key(key, &block)
94
+ self._key = key
95
+ return self.execute(&block) if block
96
+ return self
97
+ end
98
+
99
+ def execute
100
+ if self._sepconn
101
+ db = Webhookdb::Idempotency.separate_connection
102
+ else
103
+ db = Webhookdb::Idempotency.db
104
+ Webhookdb::Postgres.check_transaction(
105
+ db,
106
+ "Cannot use idempotency while already in a transaction, since side effects may not be idempotent. " \
107
+ "You can chain withusing_seperate_connection to run the idempotency itself separately.",
108
+ )
109
+ end
110
+
111
+ db[:idempotencies].insert_conflict.insert(key: self._key)
112
+ db.transaction do
113
+ idem_row = db[:idempotencies].where(key: self._key).for_update.first
114
+ if idem_row.fetch(:last_run).nil?
115
+ result = yield()
116
+ result = self._update_row(db, result)
117
+ return result
118
+ end
119
+ noop_result = self._stored ? idem_row.fetch(:stored_result) : NOOP
120
+ return noop_result if self._once_ever
121
+ return noop_result if Time.now < (idem_row[:last_run] + self._every)
66
122
  result = yield()
67
- idem.update(last_run: Time.now)
123
+ result = self._update_row(db, result)
68
124
  return result
69
125
  end
70
- return NOOP if self.__once_ever
71
- return NOOP if Time.now < (idem.last_run + self.__every)
72
- result = yield()
73
- idem.update(last_run: Time.now)
126
+ end
127
+
128
+ def _update_row(db, result)
129
+ updates = {last_run: Time.now}
130
+ if self._stored
131
+ result = result.as_json
132
+ updates[:stored_result] = Sequel.pg_jsonb_wrap(result)
133
+ end
134
+ db[:idempotencies].where(key: self._key).update(updates)
74
135
  return result
75
136
  end
76
137
  end
@@ -19,13 +19,32 @@ class Webhookdb::Jobs::Backfill
19
19
  end
20
20
 
21
21
  def _perform(event)
22
- bfjob = self.lookup_model(Webhookdb::BackfillJob, event.payload)
22
+ begin
23
+ bfjob = self.lookup_model(Webhookdb::BackfillJob, event.payload)
24
+ rescue RuntimeError => e
25
+ self.logger.info "skipping_missing_backfill_job", error: e
26
+ return
27
+ end
23
28
  sint = bfjob.service_integration
24
29
  self.with_log_tags(sint.log_tags.merge(backfill_job_id: bfjob.opaque_id)) do
25
- if bfjob.finished?
26
- self.logger.info "skipping_finished_backfill_job"
27
- else
28
- sint.replicator.backfill(bfjob)
30
+ sint.db.transaction do
31
+ unless bfjob.lock?
32
+ self.logger.info "skipping_locked_backfill_job"
33
+ break
34
+ end
35
+ bfjob.refresh
36
+ if bfjob.finished?
37
+ self.logger.info "skipping_finished_backfill_job"
38
+ break
39
+ end
40
+ begin
41
+ sint.replicator.backfill(bfjob)
42
+ rescue Webhookdb::Replicator::CredentialsMissing
43
+ # The credentials could have been cleared out, so just finish this job.
44
+ self.logger.info "skipping_backfill_job_without_credentials"
45
+ bfjob.update(finished_at: Time.now)
46
+ break
47
+ end
29
48
  end
30
49
  end
31
50
  end
@@ -3,6 +3,10 @@
3
3
  require "webhookdb/async/scheduled_job"
4
4
  require "webhookdb/jobs"
5
5
 
6
+ # For every IcalendarCalendar row needing a sync (across all service integrations),
7
+ # enqueue a +Webhookdb::Jobs::IcalendarSync+ job.
8
+ # Jobs are 'splayed' over 1/4 of the configured calendar sync period (see +Webhookdb::Icalendar+)
9
+ # to avoid a thundering herd.
6
10
  class Webhookdb::Jobs::IcalendarEnqueueSyncs
7
11
  extend Webhookdb::Async::ScheduledJob
8
12
 
@@ -10,12 +14,14 @@ class Webhookdb::Jobs::IcalendarEnqueueSyncs
10
14
  splay 30
11
15
 
12
16
  def _perform
17
+ max_splay = Webhookdb::Icalendar.sync_period_hours.hours.to_i / 4
13
18
  Webhookdb::ServiceIntegration.dataset.where_each(service_name: "icalendar_calendar_v1") do |sint|
14
19
  sint.replicator.admin_dataset do |ds|
15
20
  sint.replicator.rows_needing_sync(ds).each do |row|
16
21
  calendar_external_id = row.fetch(:external_id)
17
22
  self.with_log_tags(sint.log_tags) do
18
- enqueued_job_id = Webhookdb::Jobs::IcalendarSync.perform_async(sint.id, calendar_external_id)
23
+ splay = rand(1..max_splay)
24
+ enqueued_job_id = Webhookdb::Jobs::IcalendarSync.perform_in(splay, sint.id, calendar_external_id)
19
25
  self.logger.info("enqueued_icalendar_sync", calendar_external_id:, enqueued_job_id:)
20
26
  end
21
27
  end
@@ -15,8 +15,8 @@ class Webhookdb::Jobs::PrepareDatabaseConnections
15
15
  org.db.transaction do
16
16
  # If creating the public host fails, we end up with an orphaned database,
17
17
  # but that's not a big deal- we can eventually see it's empty/unlinked and drop it.
18
- org.prepare_database_connections
19
- org.create_public_host_cname
18
+ org.prepare_database_connections(safe: true)
19
+ org.create_public_host_cname(safe: true)
20
20
  end
21
21
  end
22
22
  end
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "webhookdb/email_octopus"
4
+ require "webhookdb/github"
5
+ require "webhookdb/sponsy"
6
+ require "webhookdb/transistor"
7
+
3
8
  module Webhookdb::Jobs
4
9
  # Create a single way to do the common task of automatic scheduled backfills.
5
10
  # Each integration that needs automated backfills can add a specification here.
6
11
  module ScheduledBackfills
7
- Spec = Struct.new(:klass, :service_name, :cron_expr, :splay, :incremental)
12
+ Spec = Struct.new(:klass, :service_name, :cron_expr, :splay, :incremental, :recursive)
8
13
 
9
14
  # @param spec [Spec]
10
15
  def self.install(spec)
@@ -16,7 +21,8 @@ module Webhookdb::Jobs
16
21
 
17
22
  define_method(:_perform) do
18
23
  Webhookdb::ServiceIntegration.dataset.where_each(service_name: spec.service_name) do |sint|
19
- Webhookdb::BackfillJob.create(service_integration: sint, incremental: spec.incremental).enqueue
24
+ m = spec.recursive ? :create_recursive : :create
25
+ Webhookdb::BackfillJob.send(m, service_integration: sint, incremental: spec.incremental).enqueue
20
26
  end
21
27
  end
22
28
  end
@@ -26,19 +32,19 @@ module Webhookdb::Jobs
26
32
  [
27
33
  Spec.new(
28
34
  "ConvertkitBroadcastBackfill", "convertkit_broadcast_v1",
29
- "10 * * * *", 2.minutes, false,
35
+ "10 * * * *", 2.minutes, false, false,
30
36
  ),
31
37
  Spec.new(
32
38
  "ConvertkitSubscriberBackfill", "convertkit_subscriber_v1",
33
- "20 * * * *", 2.minutes, true,
39
+ "20 * * * *", 2.minutes, true, false,
34
40
  ),
35
41
  Spec.new(
36
42
  "ConvertkitTagBackfill", "convertkit_tag_v1",
37
- "30 * * * *", 2.minutes, false,
43
+ "30 * * * *", 2.minutes, false, false,
38
44
  ),
39
45
  Spec.new(
40
46
  "EmailOctopusScheduledBackfill", "email_octopus_list_v1",
41
- Webhookdb::EmailOctopus.cron_expression, 2.minutes, false,
47
+ Webhookdb::EmailOctopus.cron_expression, 2.minutes, false, true,
42
48
  ),
43
49
  Spec.new(
44
50
  "GithubRepoActivityScheduledBackfill", "github_repository_event_v1",
@@ -46,31 +52,31 @@ module Webhookdb::Jobs
46
52
  ),
47
53
  Spec.new(
48
54
  "IntercomScheduledBackfill", "intercom_marketplace_root_v1",
49
- "*/1 * * * *", 0, false,
55
+ "*/1 * * * *", 0, false, true,
50
56
  ),
51
57
  Spec.new(
52
58
  "AtomSingleFeedPoller", "atom_single_feed_v1",
53
- "11 * * * *", 10.seconds, true,
59
+ "11 * * * *", 10.seconds, true, false,
54
60
  ),
55
61
  Spec.new(
56
62
  "SponsyScheduledBackfill", "sponsy_publication_v1",
57
- Webhookdb::Sponsy.cron_expression, 30.seconds, true,
63
+ Webhookdb::Sponsy.cron_expression, 30.seconds, true, true,
58
64
  ),
59
65
  Spec.new(
60
66
  "TransistorEpisodeBackfill", "transistor_episode_v1",
61
- Webhookdb::Transistor.episode_cron_expression, 2.minutes, true,
67
+ Webhookdb::Transistor.episode_cron_expression, 2.minutes, true, true,
62
68
  ),
63
69
  Spec.new(
64
70
  "TransistorShowBackfill", "transistor_show_v1",
65
- Webhookdb::Transistor.show_cron_expression, 2.minutes, true,
71
+ Webhookdb::Transistor.show_cron_expression, 2.minutes, true, false,
66
72
  ),
67
73
  Spec.new(
68
74
  "TwilioSmsBackfill", "twilio_sms_v1",
69
- "*/1 * * * *", 0, true,
75
+ "*/1 * * * *", 0, true, false,
70
76
  ),
71
77
  Spec.new(
72
78
  "SignalwireMessageBackfill", "signalwire_message_v1",
73
- "*/1 * * * *", 0, true,
79
+ "*/1 * * * *", 0, true, false,
74
80
  ),
75
81
  ].each { |sp| self.install(sp) }
76
82
  end
@@ -2,29 +2,35 @@
2
2
 
3
3
  require "webhookdb/front"
4
4
 
5
- class Webhookdb::Oauth::Front < Webhookdb::Oauth::Provider
5
+ class Webhookdb::Oauth::FrontProvider < Webhookdb::Oauth::Provider
6
6
  include Appydays::Loggable
7
7
 
8
+ # Override these for custom OAuth of different apps
8
9
  def key = "front"
9
10
  def app_name = "Front"
11
+ def client_id = Webhookdb::Front.client_id
12
+ def client_secret = Webhookdb::Front.client_secret
13
+
10
14
  def requires_webhookdb_auth? = true
11
15
  def supports_webhooks? = true
12
16
 
13
17
  def authorization_url(state:)
14
- return "https://app.frontapp.com/oauth/authorize?response_type=code&redirect_uri=#{Webhookdb::Front.oauth_callback_url}&state=#{state}&client_id=#{Webhookdb::Front.client_id}"
18
+ return "https://app.frontapp.com/oauth/authorize?response_type=code&redirect_uri=#{self.callback_url}&state=#{state}&client_id=#{self.client_id}"
15
19
  end
16
20
 
21
+ def callback_url = Webhookdb.api_url + "/v1/install/#{self.key}/callback"
22
+
17
23
  def exchange_authorization_code(code:)
18
24
  token = Webhookdb::Http.post(
19
25
  "https://app.frontapp.com/oauth/token",
20
26
  {
21
27
  "code" => code,
22
- "redirect_uri" => Webhookdb::Front.oauth_callback_url,
28
+ "redirect_uri" => self.callback_url,
23
29
  "grant_type" => "authorization_code",
24
30
  },
25
31
  logger: self.logger,
26
32
  timeout: Webhookdb::Front.http_timeout,
27
- basic_auth: {username: Webhookdb::Front.client_id, password: Webhookdb::Front.client_secret},
33
+ basic_auth: {username: self.client_id, password: self.client_secret},
28
34
  )
29
35
  return Webhookdb::Oauth::Tokens.new(
30
36
  access_token: token.parsed_response["access_token"],
@@ -56,3 +62,14 @@ class Webhookdb::Oauth::Front < Webhookdb::Oauth::Provider
56
62
  root_sint.replicator.build_dependents
57
63
  end
58
64
  end
65
+
66
+ class Webhookdb::Oauth::FrontSignalwireChannelProvider < Webhookdb::Oauth::FrontProvider
67
+ def key = "front_signalwire"
68
+ def app_name = "Front Signalwire Channel"
69
+ def client_id = Webhookdb::Front.signalwire_channel_client_id
70
+ def client_secret = Webhookdb::Front.signalwire_channel_client_secret
71
+
72
+ def build_marketplace_integrations(*)
73
+ raise NotImplementedError, "this should not be called, Front channels have a different setup"
74
+ end
75
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "webhookdb/intercom"
4
4
 
5
- class Webhookdb::Oauth::Intercom < Webhookdb::Oauth::Provider
5
+ class Webhookdb::Oauth::IntercomProvider < Webhookdb::Oauth::Provider
6
6
  include Appydays::Loggable
7
7
 
8
8
  def key = "intercom"
@@ -56,9 +56,9 @@ module Webhookdb::Oauth
56
56
  # rubocop:enable Lint/UnusedMethodArgument
57
57
 
58
58
  class << self
59
- # @return [String, Class]
60
- def register(key, cls)
61
- raise "#{key} already registered to #{cls}" if self.registry.include?(key)
59
+ def register(cls)
60
+ key = cls.new.key
61
+ raise KeyError, "#{key} already registered to #{cls}" if self.registry.include?(key)
62
62
  self.registry[key] = cls
63
63
  end
64
64
 
@@ -74,7 +74,8 @@ module Webhookdb::Oauth
74
74
  end
75
75
  end
76
76
 
77
- require "webhookdb/oauth/front"
78
- Webhookdb::Oauth.register(Webhookdb::Oauth::Front.new.key, Webhookdb::Oauth::Front)
79
- require "webhookdb/oauth/intercom"
80
- Webhookdb::Oauth.register(Webhookdb::Oauth::Intercom.new.key, Webhookdb::Oauth::Intercom)
77
+ require "webhookdb/oauth/front_provider"
78
+ Webhookdb::Oauth.register(Webhookdb::Oauth::FrontProvider)
79
+ Webhookdb::Oauth.register(Webhookdb::Oauth::FrontSignalwireChannelProvider)
80
+ require "webhookdb/oauth/intercom_provider"
81
+ Webhookdb::Oauth.register(Webhookdb::Oauth::IntercomProvider)
@@ -7,7 +7,12 @@ class Webhookdb::Organization::Alerting
7
7
  include Appydays::Configurable
8
8
 
9
9
  configurable(:alerting) do
10
+ # Only send an alert with a given signature (replicator and error signature)
11
+ # to a given customer this often. Avoid spamming about any single replicator issue.
10
12
  setting :interval, 24.hours.to_i
13
+ # Each customer can only receive this many alerts for a given template per day.
14
+ # Avoids spamming a customer when many rows of a replicator have problems.
15
+ setting :max_alerts_per_customer_per_day, 15
11
16
  end
12
17
 
13
18
  attr_reader :org
@@ -25,9 +30,15 @@ class Webhookdb::Organization::Alerting
25
30
  "which is a unique identity for this error type, used for grouping and idempotency"
26
31
  end
27
32
  signature = message_template.signature
33
+ max_alerts_per_customer_per_day = Webhookdb::Organization::Alerting.max_alerts_per_customer_per_day
28
34
  self.org.admin_customers.each do |c|
29
35
  idemkey = "orgalert-#{signature}-#{c.id}"
30
36
  Webhookdb::Idempotency.every(Webhookdb::Organization::Alerting.interval).under_key(idemkey) do
37
+ sent_last_day = Webhookdb::Message::Delivery.
38
+ where(template: message_template.full_template_name, recipient: c).
39
+ limit(max_alerts_per_customer_per_day).
40
+ count
41
+ next unless sent_last_day < max_alerts_per_customer_per_day
31
42
  message_template.dispatch_email(c)
32
43
  end
33
44
  end
@@ -34,6 +34,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
34
34
  adder: ->(om) { om.update(organization_id: id, verified: false) },
35
35
  order: :id
36
36
  one_to_many :service_integrations, class: "Webhookdb::ServiceIntegration", order: :id
37
+ one_to_many :saved_queries, class: "Webhookdb::SavedQuery", order: :id
37
38
  one_to_many :webhook_subscriptions, class: "Webhookdb::WebhookSubscription", order: :id
38
39
  many_to_many :feature_roles, class: "Webhookdb::Role", join_table: :feature_roles_organizations, right_key: :role_id
39
40
  one_to_many :all_webhook_subscriptions,
@@ -204,6 +205,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
204
205
  end
205
206
 
206
207
  # Build the org-specific users, database, and set our connection URLs to it.
208
+ # @param safe [*] If true, noop if connection urls are set.
207
209
  def prepare_database_connections(safe: false)
208
210
  self.db.transaction do
209
211
  self.lock!
@@ -220,13 +222,17 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
220
222
  end
221
223
 
222
224
  # Create a CNAME in Cloudflare for the currently configured connection urls.
223
- def create_public_host_cname
225
+ # @param safe [*] If true, noop if the public host is set.
226
+ def create_public_host_cname(safe: false)
224
227
  self.db.transaction do
225
228
  self.lock!
226
229
  # We must have a host to create a CNAME to.
227
230
  raise Webhookdb::InvalidPrecondition, "connection urls must be set" if self.readonly_connection_url_raw.blank?
228
231
  # Should only be used once when creating the org DBs.
229
- raise Webhookdb::InvalidPrecondition, "public_host must not be set" if self.public_host.present?
232
+ if self.public_host.present?
233
+ return if safe
234
+ raise Webhookdb::InvalidPrecondition, "public_host must not be set"
235
+ end
230
236
  # Use the raw URL, even though we know at this point
231
237
  # public_host is empty so raw and public host urls are the same.
232
238
  Webhookdb::Organization::DbBuilder.new(self).create_public_host_cname(self.readonly_connection_url_raw)
@@ -290,6 +290,25 @@ module Webhookdb::Postgres::ModelUtilities
290
290
  end
291
291
  end
292
292
 
293
+ # Run a SELECT FOR UPDATE SKIP LOCKED.
294
+ # If the model row is already locked, return false.
295
+ # Otherwise, acquire the lock and return true.
296
+ #
297
+ # If the lock is acquired, callers may want to refresh the receiver to make sure it has the newest values.
298
+ #
299
+ # @raise [Webhookdb::LockFailed] if the code is not currently in a transaction.
300
+ def lock?
301
+ raise Webhookdb::LockFailed, "must be in a transaction" unless self.db.in_transaction?
302
+ pk = self[self.class.primary_key]
303
+ got_lock = self.class.dataset.
304
+ select(1).
305
+ where(self.class.primary_key => pk).
306
+ for_update.
307
+ skip_locked.
308
+ first
309
+ return !got_lock.nil?
310
+ end
311
+
293
312
  # Round +Time+ t to remove nanoseconds, since Postgres can only store microseconds.
294
313
  protected def round_time(t)
295
314
  return nil if t.nil?
@@ -60,6 +60,7 @@ module Webhookdb::Postgres
60
60
  "webhookdb/organization/database_migration",
61
61
  "webhookdb/organization_membership",
62
62
  "webhookdb/role",
63
+ "webhookdb/saved_query",
63
64
  "webhookdb/service_integration",
64
65
  "webhookdb/subscription",
65
66
  "webhookdb/sync_target",
@@ -120,11 +121,13 @@ module Webhookdb::Postgres
120
121
  end
121
122
 
122
123
  def self.run_all_migrations(target: nil)
124
+ # :nocov:
123
125
  Sequel.extension :migration
124
126
  Webhookdb::Postgres.each_model_superclass do |cls|
125
127
  cls.install_all_extensions
126
128
  Sequel::Migrator.run(cls.db, Pathname(__FILE__).dirname.parent.parent + "db/migrations", target:)
127
129
  end
130
+ # :nocov:
128
131
  end
129
132
 
130
133
  # We can always register the models right away, since it does not have a side effect.