mobiscroll-connect 1.1.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49b200acf379cde0578e8f4b74e414b7a9fc5c71209f76e564654c74ece51959
4
- data.tar.gz: 8462c4b2e4ad3c41f29d69ccd6010c21c5b598a1eb7a319d7502fd0ee6186da4
3
+ metadata.gz: b741101bdf4d7452a820bd7b988d9dff51f02cf4683d867fb332292ab0a4177e
4
+ data.tar.gz: 1516abffa7aea7972ebaf558c77bcd8999c397606bf66b9cf27f7519bc5198ea
5
5
  SHA512:
6
- metadata.gz: 76adf999c8a02d7ef85a7f06e6cc537dde75b9d689ba9be0608489040a4f05b696fc1cf8002fabab6b3086deff6a3f54080ff8e83191a12b8532368c47168c94
7
- data.tar.gz: '09b3556482b18761984dfa400dbe11b26288c98176afb662bbdd4ca41a2ace1768636806cab1a63a21ec1b536d16ac088b49982134d03b1ef33bb7e437c3ed2e'
6
+ metadata.gz: 5778815c08e0d3cda4217a0c0b559ef6ad9e840b7c83760409875a446c9bcfcd9d26ee96caa18c74ef4ba194e9d51e6974e1b32d3ce58daec68fbb2ab510b52c
7
+ data.tar.gz: '089dd3ea229d309050d63097ce83bc2b4f74a2c2513b1668a716154563b5a9d01e9e9a4ebe4ab384f1c9f9d83a3f7f804ac2dcbbc8681d831817f26a18412934'
data/README.md CHANGED
@@ -35,7 +35,7 @@ client = Mobiscroll::Connect::Client.new(
35
35
  ```ruby
36
36
  url = client.auth.generate_auth_url(
37
37
  user_id: 'user-123',
38
- lng: 'es', # optional: Connect page language ('en', 'es', 'fr', 'ar')
38
+ lng: 'es', # optional: Connect page language, see https://mobiscroll.com/docs/connect/localization#supported-languages
39
39
  providers: [
40
40
  Mobiscroll::Connect::Provider::GOOGLE,
41
41
  Mobiscroll::Connect::Provider::MICROSOFT
@@ -69,7 +69,12 @@ client.set_credentials(
69
69
  ```ruby
70
70
  status = client.auth.get_connection_status
71
71
  status.connections.each do |provider, accounts|
72
- accounts.each { |a| puts "#{provider}: #{a.display}" }
72
+ accounts.each do |a|
73
+ puts "#{provider}: #{a.display}"
74
+ # Google's consent screen lets the user untick the calendar permission and still
75
+ # finish signing in. Such an account is connected but lists no calendars.
76
+ puts " #{a.id} must reconnect and allow calendar access" if a.calendar_permission_granted == false
77
+ end
73
78
  end
74
79
  ```
75
80
 
@@ -165,6 +170,29 @@ client.events.delete(
165
170
  )
166
171
  ```
167
172
 
173
+ ## Webhooks
174
+
175
+ ### Subscribe to calendar change notifications
176
+
177
+ ```ruby
178
+ result = client.webhooks.subscribe_webhook(
179
+ provider: Mobiscroll::Connect::Provider::GOOGLE,
180
+ calendar_id: 'primary'
181
+ )
182
+ puts result.channel_id
183
+ puts result.server_webhook_url
184
+ ```
185
+
186
+ ### Unsubscribe
187
+
188
+ ```ruby
189
+ client.webhooks.unsubscribe_webhook(
190
+ provider: 'google',
191
+ channel_id: result.channel_id,
192
+ resource_id: result.subscription.resource_id
193
+ )
194
+ ```
195
+
168
196
  ## Error handling
169
197
 
170
198
  All errors are subclasses of `Mobiscroll::Connect::Error`:
@@ -3,7 +3,7 @@
3
3
  module Mobiscroll
4
4
  module Connect
5
5
  class Client
6
- attr_reader :auth, :calendars, :events
6
+ attr_reader :auth, :calendars, :events, :webhooks
7
7
 
8
8
  def initialize(client_id:, client_secret:, redirect_uri:,
9
9
  base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
@@ -20,6 +20,7 @@ module Mobiscroll
20
20
  @auth = Resources::Auth.new(@config, @api_client)
21
21
  @calendars = Resources::Calendars.new(@config, @api_client)
22
22
  @events = Resources::Events.new(@config, @api_client)
23
+ @webhooks = Resources::Webhooks.new(@config, @api_client)
23
24
  end
24
25
 
25
26
  def set_credentials(tokens)
@@ -17,6 +17,25 @@ module Mobiscroll
17
17
  end
18
18
  end
19
19
 
20
+ # Raised when no connected account has the calendar access the request needs.
21
+ #
22
+ # The user completed sign-in but did not grant the calendar permission — Google's
23
+ # consent screen presents it as a separate checkbox. This cannot be repaired
24
+ # server-side, because providers only issue permissions at consent time: the
25
+ # accounts in +accounts+ have to run the connect flow again and allow access.
26
+ #
27
+ # Subclasses AuthenticationError, so existing rescue clauses keep working.
28
+ class CalendarPermissionError < AuthenticationError
29
+ # Array of BlockedAccount structs that must reconnect.
30
+ attr_reader :accounts
31
+
32
+ def initialize(message = 'No connected account has calendar access', accounts: [])
33
+ super(message)
34
+ @code = 'CALENDAR_PERMISSION_REQUIRED'
35
+ @accounts = accounts
36
+ end
37
+ end
38
+
20
39
  class NotFoundError < Error
21
40
  def initialize(message = 'Resource not found')
22
41
  super(message, code: 'NOT_FOUND_ERROR')
@@ -67,7 +86,15 @@ module Mobiscroll
67
86
 
68
87
  case status
69
88
  when 401, 403
70
- AuthenticationError.new(message)
89
+ # A 403 the caller can act on: the account connected but never granted
90
+ # calendar access, so it is raised as its own type naming the accounts.
91
+ hash = body.is_a?(Hash) ? body : {}
92
+ if status == 403 && (hash['code'] || hash[:code]) == 'calendar_permission_required'
93
+ raw = hash['accounts'] || hash[:accounts]
94
+ CalendarPermissionError.new(message, accounts: Array(raw).filter_map { |a| BlockedAccount.from_h(a) })
95
+ else
96
+ AuthenticationError.new(message)
97
+ end
71
98
  when 404
72
99
  NotFoundError.new(message)
73
100
  when 400, 422
@@ -35,12 +35,50 @@ module Mobiscroll
35
35
  end
36
36
  end
37
37
 
38
- # One connected account under a provider.
39
- ConnectedAccount = Struct.new(:id, :display, keyword_init: true) do
38
+ # A connected account that withheld calendar access on its provider's consent screen.
39
+ BlockedAccount = Struct.new(:provider, :account, keyword_init: true) do
40
40
  def self.from_h(hash)
41
41
  return nil if hash.nil?
42
42
 
43
- new(id: hash['id'] || hash[:id], display: hash['display'] || hash[:display])
43
+ new(
44
+ provider: hash['provider'] || hash[:provider],
45
+ account: hash['account'] || hash[:account]
46
+ )
47
+ end
48
+ end
49
+
50
+ # One connected calendar account.
51
+ #
52
+ # `granted_scopes` are the scopes the provider actually granted, which are not
53
+ # necessarily the ones Connect asked for: Google's consent screen lets the user untick
54
+ # the calendar permission and still complete sign-in.
55
+ #
56
+ # `calendar_permission_granted` is false for exactly those accounts — connected, but
57
+ # no calendars can be read from them until the user reconnects and allows access. It
58
+ # is nil when the question does not apply (Apple and CalDav authenticate with a
59
+ # username and app password) or no scopes were recorded for the account.
60
+ # `sync_state` reports whether the stored credentials still work: 'active', or
61
+ # 'reauth_required' once the provider has rejected them. It answers a different question
62
+ # from `calendar_permission_granted`, which records what was agreed at consent time and
63
+ # never changes afterwards. An account with no observed failure is 'active'. Recovery is
64
+ # the same reconnect — it cannot be repaired server-side.
65
+ ConnectedAccount = Struct.new(
66
+ :id, :display, :granted_scopes, :calendar_permission_granted, :sync_state, :sync_state_updated_at,
67
+ keyword_init: true
68
+ ) do
69
+ def self.from_h(hash)
70
+ return nil if hash.nil?
71
+
72
+ key = 'calendarPermissionGranted'
73
+ granted = hash.key?(key) ? hash[key] : hash[key.to_sym]
74
+ new(
75
+ id: hash['id'] || hash[:id],
76
+ display: hash['display'] || hash[:display],
77
+ granted_scopes: Array(hash['grantedScopes'] || hash[:grantedScopes]),
78
+ calendar_permission_granted: granted,
79
+ sync_state: hash['syncState'] || hash[:syncState] || 'active',
80
+ sync_state_updated_at: hash['syncStateUpdatedAt'] || hash[:syncStateUpdatedAt]
81
+ )
44
82
  end
45
83
  end
46
84
 
@@ -172,5 +210,47 @@ module Mobiscroll
172
210
  )
173
211
  end
174
212
  end
213
+
214
+ # The channel details returned by Webhooks#subscribe_webhook. `resource_id` is
215
+ # present for Google; `expiration` is an ISO date string, present for some providers.
216
+ WebhookSubscription = Struct.new(:channel_id, :resource_id, :expiration, keyword_init: true) do
217
+ def self.from_h(hash)
218
+ return nil if hash.nil?
219
+
220
+ new(
221
+ channel_id: hash['channelId'] || hash[:channelId],
222
+ resource_id: hash['resourceId'] || hash[:resourceId],
223
+ expiration: hash['expiration'] || hash[:expiration]
224
+ )
225
+ end
226
+ end
227
+
228
+ # Response from Webhooks#subscribe_webhook.
229
+ SubscribeWebhookResponse = Struct.new(
230
+ :success, :provider, :subscription, :server_webhook_url, :channel_id, keyword_init: true
231
+ ) do
232
+ def self.from_h(hash)
233
+ return nil if hash.nil?
234
+
235
+ new(
236
+ success: hash['success'] || hash[:success],
237
+ provider: hash['provider'] || hash[:provider],
238
+ subscription: WebhookSubscription.from_h(hash['subscription'] || hash[:subscription]),
239
+ server_webhook_url: hash['serverWebhookUrl'] || hash[:serverWebhookUrl],
240
+ channel_id: hash['channelId'] || hash[:channelId]
241
+ )
242
+ end
243
+ end
244
+
245
+ # Response from Webhooks#unsubscribe_webhook. `success` is true whenever the local
246
+ # mapping was removed, even if the provider-side unsubscribe itself failed — `message`
247
+ # explains what happened in that case.
248
+ UnsubscribeWebhookResponse = Struct.new(:success, :message, keyword_init: true) do
249
+ def self.from_h(hash)
250
+ return nil if hash.nil?
251
+
252
+ new(success: hash['success'] || hash[:success], message: hash['message'] || hash[:message])
253
+ end
254
+ end
175
255
  end
176
256
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Mobiscroll
6
+ module Connect
7
+ module Resources
8
+ class Webhooks
9
+ def initialize(_config, api_client)
10
+ @api_client = api_client
11
+ end
12
+
13
+ # Subscribes to push notifications for changes on a calendar.
14
+ #
15
+ # `channel_id` is auto-generated server-side when omitted. `expiration` is a
16
+ # provider-specific timestamp (ms epoch) the caller can suggest.
17
+ def subscribe_webhook(provider:, calendar_id:, channel_id: nil, expiration: nil)
18
+ body = { 'provider' => provider, 'calendarId' => calendar_id }
19
+ body['channelId'] = channel_id if channel_id
20
+ body['expiration'] = expiration if expiration
21
+
22
+ parsed = @api_client.post('/subscribe-webhook', body: JSON.generate(body))
23
+ SubscribeWebhookResponse.from_h(parsed)
24
+ end
25
+
26
+ # Unsubscribes a previously created webhook channel. `resource_id` is required by
27
+ # some providers (e.g. Google) to fully unsubscribe.
28
+ #
29
+ # The backend treats a 200 response as final even when the provider-side
30
+ # unsubscribe itself failed (e.g. an already-expired subscription) — the local
31
+ # mapping is removed either way, and `message` explains what happened.
32
+ def unsubscribe_webhook(provider:, channel_id:, resource_id: nil)
33
+ body = { 'provider' => provider, 'channelId' => channel_id }
34
+ body['resourceId'] = resource_id if resource_id
35
+
36
+ parsed = @api_client.post('/unsubscribe-webhook', body: JSON.generate(body))
37
+ UnsubscribeWebhookResponse.from_h(parsed)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mobiscroll
4
4
  module Connect
5
- VERSION = '1.1.0'
5
+ VERSION = '1.5.0'
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative 'connect/api_client'
9
9
  require_relative 'connect/resources/auth'
10
10
  require_relative 'connect/resources/calendars'
11
11
  require_relative 'connect/resources/events'
12
+ require_relative 'connect/resources/webhooks'
12
13
  require_relative 'connect/client'
13
14
 
14
15
  module Mobiscroll
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mobiscroll-connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mobiscroll
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -101,6 +101,7 @@ files:
101
101
  - lib/mobiscroll/connect/resources/auth.rb
102
102
  - lib/mobiscroll/connect/resources/calendars.rb
103
103
  - lib/mobiscroll/connect/resources/events.rb
104
+ - lib/mobiscroll/connect/resources/webhooks.rb
104
105
  - lib/mobiscroll/connect/version.rb
105
106
  - mobiscroll-connect.gemspec
106
107
  homepage: https://mobiscroll.com/connect