webhookdb 1.2.2 → 1.3.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/admin-dist/assets/index-6aebf805.js +264 -0
  3. data/admin-dist/favicon.ico +0 -0
  4. data/admin-dist/index.html +130 -0
  5. data/admin-dist/manifest.json +15 -0
  6. data/data/messages/replicators/url-recorder.liquid +20 -0
  7. data/data/messages/templates/errors/signalwire_send_sms.email.liquid +31 -0
  8. data/data/messages/web/install-customer-login.liquid +6 -5
  9. data/data/messages/web/install-error.liquid +1 -1
  10. data/data/messages/web/install-forbidden.liquid +25 -0
  11. data/data/messages/web/install-org-chooser.liquid +40 -0
  12. data/data/messages/web/install-success.liquid +2 -1
  13. data/data/messages/web/install.liquid +2 -1
  14. data/data/messages/web/partials/head.liquid +2 -0
  15. data/data/messages/web/styles.liquid +24 -0
  16. data/db/migrations/041_views.rb +20 -0
  17. data/db/migrations/042_sint_lock.rb +10 -0
  18. data/db/migrations/043_text_search.rb +28 -0
  19. data/db/migrations/044_oauth_session_token_cache.rb +21 -0
  20. data/integration/auth_spec.rb +2 -2
  21. data/lib/sequel/plugins/text_searchable.rb +165 -0
  22. data/lib/sequel/text_searchable.rb +42 -0
  23. data/lib/webhookdb/admin_api/auth.rb +24 -3
  24. data/lib/webhookdb/admin_api/data_provider.rb +196 -0
  25. data/lib/webhookdb/admin_api/entities.rb +143 -28
  26. data/lib/webhookdb/admin_api.rb +0 -2
  27. data/lib/webhookdb/api/auth.rb +5 -6
  28. data/lib/webhookdb/api/db.rb +31 -6
  29. data/lib/webhookdb/api/entities.rb +7 -1
  30. data/lib/webhookdb/api/helpers.rb +6 -25
  31. data/lib/webhookdb/api/install.rb +204 -79
  32. data/lib/webhookdb/api/organizations.rb +14 -12
  33. data/lib/webhookdb/api/saved_queries.rb +9 -3
  34. data/lib/webhookdb/api/saved_views.rb +99 -0
  35. data/lib/webhookdb/api/service_integrations.rb +15 -9
  36. data/lib/webhookdb/api/subscriptions.rb +3 -1
  37. data/lib/webhookdb/api/sync_targets.rb +9 -7
  38. data/lib/webhookdb/api/system.rb +1 -0
  39. data/lib/webhookdb/api/webhook_subscriptions.rb +3 -1
  40. data/lib/webhookdb/apps.rb +30 -7
  41. data/lib/webhookdb/async/audit_logger.rb +2 -0
  42. data/lib/webhookdb/async.rb +5 -0
  43. data/lib/webhookdb/backfill_job/service_integration_lock.rb +22 -0
  44. data/lib/webhookdb/backfill_job.rb +9 -0
  45. data/lib/webhookdb/customer.rb +5 -0
  46. data/lib/webhookdb/database_document.rb +1 -1
  47. data/lib/webhookdb/db_adapter/default_sql.rb +1 -1
  48. data/lib/webhookdb/db_adapter.rb +20 -4
  49. data/lib/webhookdb/fixtures/message_bodies.rb +34 -0
  50. data/lib/webhookdb/fixtures/organizations.rb +5 -0
  51. data/lib/webhookdb/fixtures/roles.rb +14 -0
  52. data/lib/webhookdb/fixtures/saved_views.rb +25 -0
  53. data/lib/webhookdb/fixtures/webhook_subscription_deliveries.rb +18 -0
  54. data/lib/webhookdb/http.rb +8 -2
  55. data/lib/webhookdb/icalendar.rb +3 -0
  56. data/lib/webhookdb/idempotency.rb +69 -22
  57. data/lib/webhookdb/increase.rb +69 -21
  58. data/lib/webhookdb/intercom.rb +10 -3
  59. data/lib/webhookdb/jobs/backfill.rb +3 -1
  60. data/lib/webhookdb/jobs/emailer.rb +0 -1
  61. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +19 -0
  62. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +1 -1
  63. data/lib/webhookdb/jobs/icalendar_sync.rb +1 -1
  64. data/lib/webhookdb/jobs/increase_event_handler.rb +20 -0
  65. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -1
  66. data/lib/webhookdb/jobs/sync_target_run_sync.rb +3 -1
  67. data/lib/webhookdb/message/body.rb +6 -4
  68. data/lib/webhookdb/message/delivery.rb +2 -0
  69. data/lib/webhookdb/messages/error_icalendar_fetch.rb +1 -2
  70. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +48 -0
  71. data/lib/webhookdb/oauth/fake_provider.rb +44 -0
  72. data/lib/webhookdb/oauth/front_provider.rb +1 -2
  73. data/lib/webhookdb/oauth/increase_provider.rb +80 -0
  74. data/lib/webhookdb/oauth/intercom_provider.rb +3 -11
  75. data/lib/webhookdb/oauth/session.rb +20 -0
  76. data/lib/webhookdb/oauth.rb +7 -21
  77. data/lib/webhookdb/organization/alerting.rb +2 -0
  78. data/lib/webhookdb/organization/database_migration.rb +3 -0
  79. data/lib/webhookdb/organization.rb +37 -6
  80. data/lib/webhookdb/organization_membership.rb +14 -7
  81. data/lib/webhookdb/postgres.rb +2 -0
  82. data/lib/webhookdb/replicator/base.rb +1 -0
  83. data/lib/webhookdb/replicator/docgen.rb +9 -1
  84. data/lib/webhookdb/replicator/fake.rb +2 -3
  85. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +49 -14
  86. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +97 -17
  87. data/lib/webhookdb/replicator/icalendar_event_v1.rb +104 -2
  88. data/lib/webhookdb/replicator/increase_account_number_v1.rb +6 -43
  89. data/lib/webhookdb/replicator/increase_account_transfer_v1.rb +7 -24
  90. data/lib/webhookdb/replicator/increase_account_v1.rb +7 -31
  91. data/lib/webhookdb/replicator/increase_ach_transfer_v1.rb +5 -43
  92. data/lib/webhookdb/replicator/increase_app_v1.rb +78 -0
  93. data/lib/webhookdb/replicator/increase_check_transfer_v1.rb +23 -29
  94. data/lib/webhookdb/replicator/increase_event_v1.rb +41 -0
  95. data/lib/webhookdb/replicator/increase_limit_v1.rb +9 -34
  96. data/lib/webhookdb/replicator/increase_transaction_v1.rb +5 -30
  97. data/lib/webhookdb/replicator/increase_v1_mixin.rb +58 -78
  98. data/lib/webhookdb/replicator/increase_wire_transfer_v1.rb +5 -24
  99. data/lib/webhookdb/replicator/intercom_contact_v1.rb +51 -4
  100. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +42 -6
  101. data/lib/webhookdb/replicator/intercom_marketplace_root_v1.rb +2 -13
  102. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +20 -16
  103. data/lib/webhookdb/replicator/oauth_refresh_access_token_mixin.rb +1 -1
  104. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  105. data/lib/webhookdb/replicator/transistor_episode_v1.rb +17 -0
  106. data/lib/webhookdb/replicator/url_recorder_v1.rb +137 -0
  107. data/lib/webhookdb/replicator/webhook_request.rb +4 -0
  108. data/lib/webhookdb/replicator.rb +8 -0
  109. data/lib/webhookdb/role.rb +5 -2
  110. data/lib/webhookdb/saved_query.rb +23 -0
  111. data/lib/webhookdb/saved_view.rb +73 -0
  112. data/lib/webhookdb/sentry.rb +2 -0
  113. data/lib/webhookdb/service/entities.rb +0 -4
  114. data/lib/webhookdb/service/helpers.rb +5 -0
  115. data/lib/webhookdb/service/middleware.rb +9 -0
  116. data/lib/webhookdb/service/types.rb +10 -8
  117. data/lib/webhookdb/service/validators.rb +1 -2
  118. data/lib/webhookdb/service/view_api.rb +1 -1
  119. data/lib/webhookdb/service_integration.rb +17 -15
  120. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +8 -8
  121. data/lib/webhookdb/spec_helpers/whdb.rb +3 -2
  122. data/lib/webhookdb/subscription.rb +2 -0
  123. data/lib/webhookdb/sync_target.rb +10 -2
  124. data/lib/webhookdb/tasks/message.rb +3 -1
  125. data/lib/webhookdb/version.rb +1 -1
  126. data/lib/webhookdb/webhook_subscription/delivery.rb +2 -0
  127. data/lib/webhookdb/webhook_subscription.rb +2 -0
  128. metadata +57 -9
  129. data/lib/webhookdb/admin_api/customers.rb +0 -63
  130. data/lib/webhookdb/admin_api/message_deliveries.rb +0 -61
  131. data/lib/webhookdb/admin_api/roles.rb +0 -15
@@ -22,3 +22,23 @@ class Webhookdb::Oauth::Session < Webhookdb::Postgres::Model(:oauth_sessions)
22
22
  }
23
23
  end
24
24
  end
25
+
26
+ # Table: oauth_sessions
27
+ # -------------------------------------------------------------------------------------------------------
28
+ # Columns:
29
+ # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
30
+ # created_at | timestamp with time zone | NOT NULL DEFAULT now()
31
+ # customer_id | integer |
32
+ # organization_id | integer |
33
+ # user_agent | text | NOT NULL
34
+ # peer_ip | inet | NOT NULL
35
+ # oauth_state | text | NOT NULL
36
+ # authorization_code | text |
37
+ # used_at | timestamp with time zone |
38
+ # Indexes:
39
+ # oauth_sessions_pkey | PRIMARY KEY btree (id)
40
+ # oauth_sessions_customer_id_index | btree (customer_id)
41
+ # Foreign key constraints:
42
+ # oauth_sessions_customer_id_fkey | (customer_id) REFERENCES customers(id) ON DELETE CASCADE
43
+ # oauth_sessions_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
44
+ # -------------------------------------------------------------------------------------------------------
@@ -11,7 +11,6 @@ module Webhookdb::Oauth
11
11
  end
12
12
  end
13
13
 
14
- # rubocop:disable Lint/UnusedMethodArgument
15
14
  class Provider
16
15
  # @return [String] Unique key to identify the provider.
17
16
  def key = raise NotImplementedError
@@ -19,13 +18,6 @@ module Webhookdb::Oauth
19
18
  # @return [String] Name of the app to present to users.
20
19
  def app_name = raise NotImplementedError
21
20
 
22
- # True if auth with this provider requires the user auth in WebhookDB,
23
- # false if we can get their email from the Oauth process.
24
- # If the access token can be used to get the 'me' user,
25
- # we can usually use their email for the customer,
26
- # but this may not be possible for some integrations.
27
- def requires_webhookdb_auth? = raise NotImplementedError
28
-
29
21
  # This is similar to `supports_webhooks` in the Replicator descriptors,
30
22
  # except that this is used to make the success page dynamic.
31
23
  # True if this provider's integrations support webhooks
@@ -39,22 +31,12 @@ module Webhookdb::Oauth
39
31
  # @return [Webhookdb::Oauth::Tokens]
40
32
  def exchange_authorization_code(code:) = raise NotImplementedError
41
33
 
42
- # @param tokens [Webhookdb::Oauth::Tokens]
43
- # @param scope [Hash] Used to store data needed in later calls, like when building integrations.
44
- # @return [Array{TrueClass, FalseClass, Webhookdb::Customer}]
45
- def find_or_create_customer(tokens:, scope:)
46
- raise RuntimeError("should not be called") if self.requires_webhookdb_auth?
47
- raise NotImplementedError
48
- end
49
-
50
34
  # Create the actual service integrations for the given org.
51
35
  # @param organization [Webhookdb::Organization]
52
36
  # @param tokens [Webhookdb::Oauth::Tokens]
53
- # # @param scope [Hash]
54
- def build_marketplace_integrations(organization:, tokens:, scope:) = raise NotImplementedError
55
- end
56
- # rubocop:enable Lint/UnusedMethodArgument
57
-
37
+ # @return [Webhookdb::ServiceIntegration]
38
+ def build_marketplace_integrations(organization:, tokens:) = raise NotImplementedError
39
+ end
58
40
  class << self
59
41
  def register(cls)
60
42
  key = cls.new.key
@@ -74,8 +56,12 @@ module Webhookdb::Oauth
74
56
  end
75
57
  end
76
58
 
59
+ require "webhookdb/oauth/fake_provider"
60
+ Webhookdb::Oauth.register(Webhookdb::Oauth::FakeProvider)
77
61
  require "webhookdb/oauth/front_provider"
78
62
  Webhookdb::Oauth.register(Webhookdb::Oauth::FrontProvider)
79
63
  Webhookdb::Oauth.register(Webhookdb::Oauth::FrontSignalwireChannelProvider)
64
+ require "webhookdb/oauth/increase_provider"
65
+ Webhookdb::Oauth.register(Webhookdb::Oauth::IncreaseProvider)
80
66
  require "webhookdb/oauth/intercom_provider"
81
67
  Webhookdb::Oauth.register(Webhookdb::Oauth::IntercomProvider)
@@ -31,11 +31,13 @@ class Webhookdb::Organization::Alerting
31
31
  end
32
32
  signature = message_template.signature
33
33
  max_alerts_per_customer_per_day = Webhookdb::Organization::Alerting.max_alerts_per_customer_per_day
34
+ yesterday = Time.now - 24.hours
34
35
  self.org.admin_customers.each do |c|
35
36
  idemkey = "orgalert-#{signature}-#{c.id}"
36
37
  Webhookdb::Idempotency.every(Webhookdb::Organization::Alerting.interval).under_key(idemkey) do
37
38
  sent_last_day = Webhookdb::Message::Delivery.
38
39
  where(template: message_template.full_template_name, recipient: c).
40
+ where { created_at > yesterday }.
39
41
  limit(max_alerts_per_customer_per_day).
40
42
  count
41
43
  next unless sent_last_day < max_alerts_per_customer_per_day
@@ -7,6 +7,7 @@ class Webhookdb::Organization::DatabaseMigration < Webhookdb::Postgres::Model(:o
7
7
  class MigrationAlreadyFinished < StandardError; end
8
8
 
9
9
  plugin :timestamps
10
+ plugin :text_searchable, terms: [:organization, :started_by]
10
11
  plugin :column_encryption do |enc|
11
12
  enc.column :source_admin_connection_url
12
13
  enc.column :destination_admin_connection_url
@@ -14,6 +15,7 @@ class Webhookdb::Organization::DatabaseMigration < Webhookdb::Postgres::Model(:o
14
15
 
15
16
  many_to_one :started_by, class: "Webhookdb::Customer"
16
17
  many_to_one :organization, class: "Webhookdb::Organization"
18
+ many_to_one :last_migrated_service_integration, class: "Webhookdb::ServiceIntegration"
17
19
 
18
20
  dataset_module do
19
21
  def ongoing
@@ -141,6 +143,7 @@ end
141
143
  # organization_schema | text |
142
144
  # last_migrated_service_integration_id | integer | NOT NULL DEFAULT 0
143
145
  # last_migrated_timestamp | timestamp with time zone |
146
+ # text_search | tsvector |
144
147
  # Indexes:
145
148
  # organization_database_migrations_pkey | PRIMARY KEY btree (id)
146
149
  # one_inprogress_migration_per_org | UNIQUE btree (organization_id) WHERE finished_at IS NULL
@@ -11,6 +11,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
11
11
 
12
12
  plugin :timestamps
13
13
  plugin :soft_deletes
14
+ plugin :text_searchable, terms: [:name, :key, :billing_email]
14
15
  plugin :column_encryption do |enc|
15
16
  enc.column :readonly_connection_url_raw
16
17
  enc.column :admin_connection_url_raw
@@ -35,6 +36,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
35
36
  order: :id
36
37
  one_to_many :service_integrations, class: "Webhookdb::ServiceIntegration", order: :id
37
38
  one_to_many :saved_queries, class: "Webhookdb::SavedQuery", order: :id
39
+ one_to_many :saved_views, class: "Webhookdb::SavedView", order: :id
38
40
  one_to_many :webhook_subscriptions, class: "Webhookdb::WebhookSubscription", order: :id
39
41
  many_to_many :feature_roles, class: "Webhookdb::Role", join_table: :feature_roles_organizations, right_key: :role_id
40
42
  one_to_many :all_webhook_subscriptions,
@@ -145,6 +147,33 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
145
147
  end
146
148
  end
147
149
 
150
+ # Run the given SQL inside the org, and use special error handling if it fails.
151
+ # @return [Array<Webhookdb::Organization::QueryResult,String,nil>] Tuple of query result, and optional message.
152
+ # On query success, return <QueryResult, nil>.
153
+ # On DatabaseError, return <nil, message>.
154
+ # On other types of errors, raise.
155
+ def execute_readonly_query_with_help(sql)
156
+ result = self.execute_readonly_query(sql)
157
+ return result, nil
158
+ rescue Sequel::DatabaseError => e
159
+ self.logger.error("db_query_database_error", error: e)
160
+ # We want to handle InsufficientPrivileges and UndefinedTable explicitly
161
+ # since we can hint the user at what to do.
162
+ # Otherwise, we should just return the Postgres exception.
163
+ msg = ""
164
+ case e.wrapped_exception
165
+ when PG::UndefinedTable
166
+ missing_table = e.wrapped_exception.message.match(/relation (.+) does not/)&.captures&.first
167
+ msg = "The table #{missing_table} does not exist. Run `webhookdb db tables` to see available tables." if
168
+ missing_table
169
+ when PG::InsufficientPrivilege
170
+ msg = "You do not have permission to perform this query. Queries must be read-only."
171
+ else
172
+ msg = e.wrapped_exception.message
173
+ end
174
+ return [nil, msg]
175
+ end
176
+
148
177
  class QueryResult
149
178
  attr_accessor :rows, :columns, :max_rows_reached
150
179
  end
@@ -355,10 +384,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
355
384
  end
356
385
 
357
386
  def migrate_replication_schema(schema)
358
- unless Webhookdb::DBAdapter::VALID_IDENTIFIER.match?(schema)
359
- msg = "Sorry, this is not a valid schema name. " + Webhookdb::DBAdapter::INVALID_IDENTIFIER_MESSAGE
360
- raise SchemaMigrationError, msg
361
- end
387
+ Webhookdb::DBAdapter.validate_identifier!(schema, type: "schema")
362
388
  Webhookdb::Organization::DatabaseMigration.guard_ongoing!(self)
363
389
  raise SchemaMigrationError, "destination and target schema are the same" if schema == self.replication_schema
364
390
  builder = Webhookdb::Organization::DbBuilder.new(self)
@@ -446,7 +472,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
446
472
  return Webhookdb::ServiceIntegration.where(organization: self).count < limit
447
473
  end
448
474
 
449
- def available_replicator_names
475
+ def available_replicators
450
476
  available = Webhookdb::Replicator.registry.values.filter do |desc|
451
477
  # The org must have any of the flags required for the service. In other words,
452
478
  # the intersection of desc[:feature_roles] & org.feature_roles must
@@ -456,7 +482,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
456
482
  org_has_access = (self.feature_roles.map(&:name) & desc.feature_roles).present?
457
483
  org_has_access
458
484
  end
459
- return available.map(&:name)
485
+ return available
460
486
  end
461
487
 
462
488
  #
@@ -498,6 +524,8 @@ require "webhookdb/organization/db_builder"
498
524
  # minimum_sync_seconds | integer | NOT NULL
499
525
  # sync_target_timeout | integer | NOT NULL DEFAULT 30
500
526
  # max_query_rows | integer |
527
+ # priority_backfill | boolean | NOT NULL DEFAULT false
528
+ # text_search | tsvector |
501
529
  # Indexes:
502
530
  # organizations_pkey | PRIMARY KEY btree (id)
503
531
  # organizations_key_key | UNIQUE btree (key)
@@ -505,8 +533,11 @@ require "webhookdb/organization/db_builder"
505
533
  # Referenced By:
506
534
  # feature_roles_organizations | feature_roles_organizations_organization_id_fkey | (organization_id) REFERENCES organizations(id)
507
535
  # logged_webhooks | logged_webhooks_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
536
+ # oauth_sessions | oauth_sessions_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
508
537
  # organization_database_migrations | organization_database_migrations_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
509
538
  # organization_memberships | organization_memberships_organization_id_fkey | (organization_id) REFERENCES organizations(id)
539
+ # saved_queries | saved_queries_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
540
+ # saved_views | saved_views_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
510
541
  # service_integrations | service_integrations_organization_id_fkey | (organization_id) REFERENCES organizations(id)
511
542
  # webhook_subscriptions | webhook_subscriptions_organization_id_fkey | (organization_id) REFERENCES organizations(id)
512
543
  # ------------------------------------------------------------------------------------------------------------------------------------------------------------
@@ -5,10 +5,16 @@ require "webhookdb/postgres/model"
5
5
  class Webhookdb::OrganizationMembership < Webhookdb::Postgres::Model(:organization_memberships)
6
6
  VALID_ROLE_NAMES = ["admin", "member"].freeze
7
7
 
8
+ plugin :text_searchable, terms: [:customer, :organization, :membership_role]
9
+
8
10
  many_to_one :organization, class: "Webhookdb::Organization"
9
11
  many_to_one :customer, class: "Webhookdb::Customer"
10
12
  many_to_one :membership_role, class: "Webhookdb::Role"
11
13
 
14
+ dataset_module do
15
+ def admin = self.where(membership_role: Webhookdb::Role.admin_role)
16
+ end
17
+
12
18
  def verified?
13
19
  return self.verified
14
20
  end
@@ -38,13 +44,14 @@ end
38
44
  # Table: organization_memberships
39
45
  # ------------------------------------------------------------------------------------------------------------------------------
40
46
  # Columns:
41
- # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
42
- # customer_id | integer | NOT NULL
43
- # organization_id | integer | NOT NULL
44
- # verified | boolean | NOT NULL
45
- # invitation_code | text | NOT NULL DEFAULT ''::text
46
- # membership_role_id | integer | NOT NULL
47
- # is_default | boolean | NOT NULL DEFAULT false
47
+ # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
48
+ # customer_id | integer | NOT NULL
49
+ # organization_id | integer | NOT NULL
50
+ # verified | boolean | NOT NULL
51
+ # invitation_code | text | NOT NULL DEFAULT ''::text
52
+ # membership_role_id | integer | NOT NULL
53
+ # is_default | boolean | NOT NULL DEFAULT false
54
+ # text_search | tsvector |
48
55
  # Indexes:
49
56
  # organization_memberships_pkey | PRIMARY KEY btree (id)
50
57
  # one_default_per_customer | UNIQUE btree (customer_id, organization_id) WHERE is_default IS TRUE
@@ -48,6 +48,7 @@ module Webhookdb::Postgres
48
48
  # Require paths for all Sequel models used by the app.
49
49
  MODELS = [
50
50
  "webhookdb/backfill_job",
51
+ "webhookdb/backfill_job/service_integration_lock",
51
52
  "webhookdb/customer",
52
53
  "webhookdb/customer/reset_code",
53
54
  "webhookdb/database_document",
@@ -61,6 +62,7 @@ module Webhookdb::Postgres
61
62
  "webhookdb/organization_membership",
62
63
  "webhookdb/role",
63
64
  "webhookdb/saved_query",
65
+ "webhookdb/saved_view",
64
66
  "webhookdb/service_integration",
65
67
  "webhookdb/subscription",
66
68
  "webhookdb/sync_target",
@@ -159,6 +159,7 @@ class Webhookdb::Replicator::Base
159
159
  def process_state_change(field, value, attr: nil)
160
160
  attr ||= field
161
161
  desc = self.descriptor
162
+ value = value.strip if value.respond_to?(:strip)
162
163
  case field
163
164
  when *self._webhook_state_change_fields
164
165
  # If we don't support webhooks, then the backfill state machine may be using it.
@@ -55,13 +55,21 @@ class Webhookdb::Replicator::Docgen
55
55
 
56
56
  def _intro
57
57
  lines << "# #{desc.resource_name_singular} (`#{desc.name}`)"
58
+ if desc.enterprise?
59
+ lines << ""
60
+ lines << "{% include enterprise_integration_list.md %}"
61
+ lines << ""
62
+ end
58
63
  if desc.description.present?
59
64
  lines << ""
60
65
  lines << desc.description
61
66
  end
67
+ lines << ""
68
+ lines << "To get set up, run this code from the [WebhookDB CLI](https://webhookdb.com/terminal):"
69
+ lines << "```\nwebhookdb integrations create #{desc.name}\n```"
62
70
  if desc.api_docs_url.present?
63
71
  lines << ""
64
- lines << "Docs for this API: [#{desc.api_docs_url}](#{desc.api_docs_url})"
72
+ lines << "Source documentation for this API: [#{desc.api_docs_url}](#{desc.api_docs_url})"
65
73
  end
66
74
  lines << ""
67
75
  end
@@ -109,9 +109,8 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
109
109
  end
110
110
 
111
111
  def _resource_and_event(request)
112
- body = request.body
113
- return self.class.resource_and_event_hook.call(body) if self.class.resource_and_event_hook
114
- return body, nil
112
+ return self.class.resource_and_event_hook.call(request) if self.class.resource_and_event_hook
113
+ return request.body, nil
115
114
  end
116
115
 
117
116
  def _update_where_expr
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "jwt"
4
4
 
5
+ require "webhookdb/messages/error_signalwire_send_sms"
5
6
  require "webhookdb/replicator/front_v1_mixin"
6
7
 
7
8
  # Front has a system of 'channels' but it is a challenge to use.
@@ -148,7 +149,7 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
148
149
  self.service_integration.save_changes
149
150
  return {type: "success", webhook_url: "#{Webhookdb.api_url}/v1/install/front_signalwire/channel"}.to_json
150
151
  when "delete"
151
- self.service_integration.destroy
152
+ self.service_integration.destroy_self_and_all_dependents
152
153
  return "{}"
153
154
  when "message", "message_autoreply"
154
155
  return {
@@ -269,27 +270,61 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
269
270
  item[:signalwire_sid] = "skipped_due_to_age"
270
271
  else
271
272
  # send the SMS via signalwire
272
- signalwire_resp = idempotency.execute do
273
- Webhookdb::Signalwire.send_sms(
274
- from: sender,
275
- to: recipient,
276
- body:,
277
- space_url: @signalwire_sint.api_url,
278
- project_id: @signalwire_sint.backfill_key,
279
- api_key: @signalwire_sint.backfill_secret,
280
- logger: @replicator.logger,
281
- )
282
- end
283
- item[:signalwire_sid] = signalwire_resp.fetch("sid")
273
+ signalwire_resp = _send_sms(
274
+ idempotency,
275
+ from: sender,
276
+ to: recipient,
277
+ body:,
278
+ )
279
+ item[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
284
280
  end
285
281
  end
286
282
  @replicator.upsert_webhook_body(item.deep_stringify_keys)
287
283
  end
288
284
 
285
+ def _send_sms(idempotency, from:, to:, body:)
286
+ return idempotency.execute do
287
+ Webhookdb::Signalwire.send_sms(
288
+ from:,
289
+ to:,
290
+ body:,
291
+ space_url: @signalwire_sint.api_url,
292
+ project_id: @signalwire_sint.backfill_key,
293
+ api_key: @signalwire_sint.backfill_secret,
294
+ logger: @replicator.logger,
295
+ )
296
+ end
297
+ rescue Webhookdb::Http::Error => e
298
+ response_body = e.body
299
+ response_status = e.status
300
+ request_url = e.uri.to_s
301
+ @replicator.logger.warn("signalwire_send_sms_error",
302
+ response_body:, response_status:, request_url:, sms_from: from, sms_to: to,)
303
+ code = begin
304
+ # If this fails for whatever reason, or there is no 'code', re-raise the original error
305
+ e.response.parsed_response["code"]
306
+ rescue StandardError
307
+ nil
308
+ end
309
+ # All known codes are for the integrator, not on the webhookdb code side.
310
+ # https://developer.signalwire.com/guides/how-to-troubleshoot-common-messaging-issues
311
+ raise e if code.nil?
312
+
313
+ message = Webhookdb::Messages::ErrorSignalwireSendSms.new(
314
+ @replicator.service_integration,
315
+ response_status:,
316
+ response_body:,
317
+ request_url:,
318
+ request_method: "POST",
319
+ )
320
+ @replicator.service_integration.organization.alerting.dispatch_alert(message)
321
+ return nil
322
+ end
323
+
289
324
  def _sync_front_inbound(sender:, texted_at:, item:, body:)
290
325
  body = {
291
326
  sender: {handle: sender},
292
- body:,
327
+ body: body || "<no body>",
293
328
  delivered_at: texted_at.to_i,
294
329
  metadata: {
295
330
  external_id: item.fetch(:external_id),
@@ -146,11 +146,12 @@ The secret to use for signing is:
146
146
 
147
147
  class Upserter
148
148
  include Webhookdb::Backfiller::Bulk
149
- attr_reader :upserting_replicator, :calendar_external_id
149
+ attr_reader :upserting_replicator, :calendar_external_id, :now
150
150
 
151
- def initialize(replicator, calendar_external_id)
151
+ def initialize(replicator, calendar_external_id, now:)
152
152
  @upserting_replicator = replicator
153
153
  @calendar_external_id = calendar_external_id
154
+ @now = now
154
155
  end
155
156
 
156
157
  def upsert_page_size = 500
@@ -158,31 +159,33 @@ The secret to use for signing is:
158
159
 
159
160
  def prepare_body(body)
160
161
  body["calendar_external_id"] = @calendar_external_id
162
+ body["row_updated_at"] = @now
161
163
  end
162
164
  end
163
165
 
164
166
  def sync_row(row)
165
167
  Appydays::Loggable.with_log_tags(icalendar_url: row.fetch(:ics_url)) do
166
168
  self.with_advisory_lock(row.fetch(:pk)) do
169
+ now = Time.now
167
170
  if (dep = self.find_dependent("icalendar_event_v1"))
168
- self._sync_row(row, dep)
171
+ self._sync_row(row, dep, now:)
169
172
  end
170
- self.admin_dataset { |ds| ds.where(pk: row.fetch(:pk)).update(last_synced_at: Time.now) }
173
+ self.admin_dataset { |ds| ds.where(pk: row.fetch(:pk)).update(last_synced_at: now) }
171
174
  end
172
175
  end
173
176
  end
174
177
 
175
- def _sync_row(row, dep)
178
+ def _sync_row(row, dep, now:)
176
179
  calendar_external_id = row.fetch(:external_id)
177
- request_url = row.fetch(:ics_url)
178
180
  begin
181
+ request_url = self._clean_ics_url(row.fetch(:ics_url))
179
182
  io = Webhookdb::Http.chunked_download(request_url, rewindable: false)
180
- rescue Down::Error => e
183
+ rescue Down::Error, URI::InvalidURIError => e
181
184
  self._handle_down_error(e, request_url:, calendar_external_id:)
182
185
  return
183
186
  end
184
187
 
185
- upserter = Upserter.new(dep.replicator, calendar_external_id)
188
+ upserter = Upserter.new(dep.replicator, calendar_external_id, now:)
186
189
  processor = EventProcessor.new(io, upserter)
187
190
  processor.process
188
191
  # Delete all the extra replicator rows, and cancel all the rows that weren't upserted.
@@ -192,12 +195,23 @@ The secret to use for signing is:
192
195
  ds.where(delete_condition).delete
193
196
  end
194
197
  # Update both the status, and set the data json to match.
195
- ds.exclude(compound_identity: processor.upserted_identities).update(
198
+ # Only update rows not already CANCELLED.
199
+ ds = ds.exclude(Sequel[compound_identity: processor.upserted_identities])
200
+ ds = ds.where(Sequel[status: nil] | ~Sequel[status: "CANCELLED"])
201
+ ds.update(
196
202
  status: "CANCELLED",
197
203
  data: Sequel.lit('data || \'{"STATUS":{"v":"CANCELLED"}}\'::jsonb'),
204
+ row_updated_at: now,
198
205
  )
199
206
  end
200
- self.admin_dataset { |ds| ds.where(pk: row.fetch(:pk)).update(last_synced_at: Time.now) }
207
+ end
208
+
209
+ # We get all sorts of strange urls, fix up what we can.
210
+ def _clean_ics_url(url)
211
+ u = URI(url)
212
+ # https://xyz.com:80 is invalid, set it to 443 which yields https://xyz.com
213
+ u.port = 443 if u.scheme == "https" && u.port == 80
214
+ return u.to_s
201
215
  end
202
216
 
203
217
  def _handle_down_error(e, request_url:, calendar_external_id:)
@@ -205,22 +219,37 @@ The secret to use for signing is:
205
219
  when Down::TooManyRedirects
206
220
  response_status = 301
207
221
  response_body = "<too many redirects>"
208
- when Down::TimeoutError, Down::SSLError, Down::ConnectionError, Down::InvalidUrl
222
+ when Down::NotModified
223
+ # Do not alert on 304, but do log
224
+ self.logger.info("icalendar_fetch_not_modified", response_status: 304, request_url:, calendar_external_id:)
225
+ return
226
+ when Down::SSLError
227
+ self._handle_retryable_down_error!(e, request_url:, calendar_external_id:)
228
+ when Down::TimeoutError, Down::ConnectionError, Down::InvalidUrl, URI::InvalidURIError
209
229
  response_status = 0
210
230
  response_body = e.to_s
211
231
  when Down::ClientError
212
232
  raise e if e.response.nil?
213
233
  response_status = e.response.code.to_i
234
+ self._handle_retryable_down_error!(e, request_url:, calendar_external_id:) if
235
+ self._retryable_client_error?(e, request_url:)
236
+ # These are all the errors we've seen, we can't do anything about.
237
+ # In theory we should do this for ALL 4xx errors,
238
+ # but we'd rather error on the WebhookDB side until we're sure
239
+ # we want to ignore things.
214
240
  expected_errors = [
241
+ 400, 401, 402, 403, # Common access problems we can't do anything about
242
+ 404, 405, # Fundamental issues with the URL given
243
+ 409, 410, # More access problems
215
244
  417, # If someone uses an Outlook HTML calendar, fetch gives us a 417
245
+ 429, # Usually 429s are retried (as above), but in some cases they're not.
216
246
  ]
217
247
  # For most client errors, we can't do anything about it. For example,
218
248
  # and 'unshared' URL could result in a 401, 403, 404, or even a 405.
219
249
  # For now, other client errors, we can raise on,
220
250
  # in case it's something we can fix/work around.
221
- is_problem_error = (response_status > 405 || response_status < 400) &&
222
- !expected_errors.include?(response_status)
223
- raise e if is_problem_error
251
+ # For example, it's possible something like a 415 is a WebhookDB issue.
252
+ raise e unless expected_errors.include?(response_status)
224
253
  response_body = e.response.body.to_s
225
254
  when Down::ServerError
226
255
  response_status = e.response.code.to_i
@@ -243,6 +272,32 @@ The secret to use for signing is:
243
272
  self.service_integration.organization.alerting.dispatch_alert(message)
244
273
  end
245
274
 
275
+ def _retryable_client_error?(e, request_url:)
276
+ code = e.response.code.to_i
277
+ # This is a bad domain that returns 429 for most requests.
278
+ # Tell the org admins it won't sync.
279
+ return false if code == 429 && request_url.start_with?("https://ical.schedulestar.com")
280
+ # Other 429s can be retried.
281
+ return true if code == 429
282
+ # Otherwise, handle the client error normally, by telling the org admins, or raising.
283
+ return false
284
+ end
285
+
286
+ def _handle_retryable_down_error!(e, request_url:, calendar_external_id:)
287
+ # Retry on these, which are hopefully transient.
288
+ # For now, if they aren't transient, die so we see the job.
289
+ # We will probably need to do an alert if the retries on exhausted instead.
290
+ retry_in = rand(4..60).minutes
291
+ self.logger.debug(
292
+ "icalendar_fetch_error_retry",
293
+ response_status: e.respond_to?(:response) ? e.response&.code : 0,
294
+ request_url:,
295
+ calendar_external_id:,
296
+ retry_at: Time.now + retry_in,
297
+ )
298
+ raise Amigo::Retry::OrDie.new(10, retry_in)
299
+ end
300
+
246
301
  class EventProcessor
247
302
  attr_reader :upserted_identities
248
303
 
@@ -378,13 +433,21 @@ The secret to use for signing is:
378
433
 
379
434
  schedule = IceCube::Schedule.from_hash(ical_params)
380
435
  dont_project_before = Webhookdb::Icalendar.oldest_recurring_event
381
- dont_project_after = Time.now + RECURRENCE_PROJECTION
436
+ dont_project_after = @upserter.now + RECURRENCE_PROJECTION
382
437
 
383
438
  # Just like google, track the original event id.
384
439
  h["recurring_event_id"] = uid
385
440
  final_sequence = -1
386
441
  begin
387
- schedule.send(:enumerate_occurrences, schedule.start_time).each_with_index do |occ, idx|
442
+ # Pass in a 'closing time' to avoid a denial of service for an impossible rrule.
443
+ # It is further into the future than the "don't project after"
444
+ # since using something too short causes the calculation to be short-circuited before it should
445
+ # (I'm unclear what the ideal value is, but tests will fail with much less than the number here).
446
+ # This still results in a slow calculation, but there's not much we can do for now.
447
+ # In the future perhaps we should try to pre-validate common problems.
448
+ # See spec for examples.
449
+ dos_cutoff = dont_project_after + 210.days
450
+ schedule.send(:enumerate_occurrences, schedule.start_time, dos_cutoff).each_with_index do |occ, idx|
388
451
  next if occ.start_time < dont_project_before
389
452
  # Given the original hash, we will modify some fields.
390
453
  e = h.dup
@@ -424,6 +487,7 @@ The secret to use for signing is:
424
487
  ical = ical.gsub(/BYMONTHDAY=[\d,]+/, "")
425
488
  ical.delete_prefix! ";"
426
489
  ical.delete_suffix! ";"
490
+ ical.squeeze!(";")
427
491
  end
428
492
  return IceCube::IcalParser.rule_from_ical(ical)
429
493
  end
@@ -443,7 +507,23 @@ The secret to use for signing is:
443
507
  vevent_lines = []
444
508
  in_vevent = false
445
509
  while (line = @io.gets)
446
- line.rstrip!
510
+ begin
511
+ line.rstrip!
512
+ rescue Encoding::CompatibilityError
513
+ # We occassionally get incorrectly encoded files.
514
+ # For example, the response may have a header:
515
+ # Content-Type: text/calendar; charset=UTF-8
516
+ # but the actual encoding is not:
517
+ # file -I <filename>
518
+ # <filename>: text/calendar; charset=iso-8859-1
519
+ # In these cases, there's not much we can do.
520
+ # We can use chardet, but it's a big library and this issue
521
+ # isn't common enough. Instead, try to force the encoding to utf-8,
522
+ # which may break some things, but we'll see what happens.
523
+ line = line.force_encoding("utf-8")
524
+ line = line.scrub
525
+ line = line.rstrip
526
+ end
447
527
  if line == "BEGIN:VEVENT"
448
528
  in_vevent = true
449
529
  vevent_lines << line