webhookdb 1.2.2 → 1.3.1

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