relaygrid 0.2.0 → 0.3.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: '08c77a1f6d4cf7d515c07899a1b7ec8e30a590921fff71bf0261cc2caa58fc86'
4
- data.tar.gz: 0a4d7357d29d5fada986ac8bc2cf67b6a87c833a410f47a1d15590665bd06d4b
3
+ metadata.gz: b882a7bed3e374b22766cb62d2d79d29ce370ac1ac9eb62c3190c3ccf0239e16
4
+ data.tar.gz: 580b2d452d74e956bce502515ed67c0db9747fc38ab40c42c80081970341d345
5
5
  SHA512:
6
- metadata.gz: fcc099fa99bc3f2312b16308b61ed94731284533fac178506a49dec418ee84bbdf118984f49f003c6076c931deb8eb2675c22f4d55e1c2966e58283125c19ef4
7
- data.tar.gz: 9376cfbafd25399ecb4bc2aa8091bdbf95712d22d6713fca2db039823ddaedc790d111b1752b9bf360a4b9613a02cfe7c46df0ef03ce5178f15fe8239829b9e6
6
+ metadata.gz: a271ee98e4bb2d8720c2e74bd75b57fcfb3eded0f7a3888bac266bba828048463fe47c6721d2aaae1f5b82a1a70a1eae2fa4d2616812f81510f04aa286232352
7
+ data.tar.gz: d7fc151625f6bfa0159839c8f6952c7d7265ab65fbf41445e863d662cd541571e815f9476c7712ccc763a3ff438be52002ca610981787f49050e08488b871488
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **Scheduled deliveries.** `notify` accepts `scheduled_at:` to deliver later
15
+ instead of immediately. A `Time`, `DateTime`, or `ActiveSupport::TimeWithZone`
16
+ is serialized with `.iso8601`, which always emits a UTC offset; a `String` is
17
+ passed through untouched for the server to validate. **The server requires an
18
+ explicit UTC offset and returns a 422 on a naive or malformed timestamp** —
19
+ it never assumes a timezone, so a client that serializes naively would
20
+ otherwise turn into a 422 the caller can't diagnose. A bare `Date` raises
21
+ `ArgumentError` locally, since a date has no instant.
22
+ - `messages.cancel_schedule(id)` withdraws a scheduled send before it
23
+ dispatches (`DELETE /api/v1/messages/:id/schedule`). Raises `ValidationError`
24
+ if the message has already dispatched or been canceled.
25
+ - `SendResult#scheduled?` and `#scheduled_at`, read off the created message.
26
+ - **`wait_for_deliveries` returns immediately for a scheduled send**, without
27
+ polling. A `scheduled` delivery is never terminal — nothing has been handed
28
+ to a provider yet — so without this a caller who scheduled and then waited
29
+ would block for the full `timeout`.
30
+ - Note for integrators: a `201` at schedule time is not a delivery guarantee.
31
+ A scheduled send can still end in `delivery.failed` if the account's
32
+ monthly quota is spent by dispatch time. `push?`/`push_token` are unaffected
33
+ by scheduling — the token is for the device to subscribe now, independent
34
+ of when the message dispatches, because the server reads the template's
35
+ live channels rather than the deliveries.
36
+
37
+ ## [0.2.1] - 2026-08-21
38
+
39
+ ### Fixed
40
+
41
+ - Gemspec metadata now points at the self-hosted GitLab repository
42
+ (`git.relaygrid.dev`): the homepage, source, and changelog URIs no longer
43
+ reference the old GitHub location. No code changes.
44
+
10
45
  ## [0.2.0] - 2026-08-06
11
46
 
12
47
  ### Added
data/QUICKSTART.md CHANGED
@@ -74,6 +74,33 @@ them when you want status. Don't block a web request waiting — see
74
74
  `wait_for_deliveries` in the README for when that helper is and isn't
75
75
  appropriate.
76
76
 
77
+ ## 6a. Schedule for later (optional)
78
+
79
+ Pass `scheduled_at` instead of sending immediately:
80
+
81
+ ```ruby
82
+ RelayGrid.client.notify(
83
+ user: current_user.id, template: "order_shipped",
84
+ scheduled_at: Time.now + 1.day # or an ISO 8601 String like "2026-09-01T09:00:00-07:00"
85
+ )
86
+ ```
87
+
88
+ **The timestamp must carry an explicit UTC offset.** A `Time`/`DateTime`/
89
+ `ActiveSupport::TimeWithZone` is serialized with `.iso8601` for you, which
90
+ always includes one; a naive `String` (no offset) is rejected by the server
91
+ with a 422 rather than assumed to mean UTC. A bare `Date` raises
92
+ `ArgumentError` locally — it has no instant to schedule.
93
+
94
+ A `201` here means the schedule was accepted, not that the message will
95
+ successfully send: a scheduled delivery can still end in `delivery.failed`
96
+ if the account's quota is spent by dispatch time. Withdraw it beforehand with:
97
+
98
+ ```ruby
99
+ RelayGrid.client.messages.cancel_schedule(result.message_id)
100
+ ```
101
+
102
+ See the [README](README.md#scheduling-a-send) for the full behavior.
103
+
77
104
  ## 7. Receive (optional)
78
105
 
79
106
  If your template has a push channel, the send hands you a token for the
@@ -104,6 +131,8 @@ ws.connect
104
131
  | `result.push_token` is `nil` | The template has no live push channel attached. |
105
132
  | Delivery stuck at `queued` | The delivery job hasn't run yet; poll again shortly. |
106
133
  | Delivery `failed` | Read `delivery.friendly_error_message` — usually channel credentials. |
134
+ | `scheduled_at` gets a 422 | The timestamp has no UTC offset, or isn't a real date/time. Use `Time`/`DateTime`/`ActiveSupport::TimeWithZone`, or an explicit-offset ISO 8601 string. |
135
+ | A scheduled send ends in `delivery.failed` | Quota was spent between scheduling and dispatch. The `201` at schedule time isn't a delivery guarantee. |
107
136
 
108
137
  ## Next steps
109
138
 
data/README.md CHANGED
@@ -123,8 +123,53 @@ result.rendered_subject # => "Hi Ada"
123
123
  result.push_token # => "eyJf..." or nil
124
124
  result.push_token_expires_at # => 2026-07-28 11:00:00 +0000
125
125
  result.push? # => true when a push channel was dispatched
126
+ result.scheduled? # => true when scheduled_at was set
127
+ result.scheduled_at # => 2026-09-01 16:00:00 +0000, or nil for an immediate send
126
128
  ```
127
129
 
130
+ ### Scheduling a send
131
+
132
+ Pass `scheduled_at` to deliver later instead of immediately:
133
+
134
+ ```ruby
135
+ RelayGrid.client.notify(
136
+ user: "user-123", template: "order_shipped",
137
+ scheduled_at: Time.now + 3600 # or a DateTime, an ActiveSupport::TimeWithZone, or an ISO 8601 String
138
+ )
139
+ ```
140
+
141
+ A `Time`, `DateTime`, or `ActiveSupport::TimeWithZone` is serialized with
142
+ `.iso8601`, which always emits a UTC offset. A `String` is passed through
143
+ untouched for the server to validate. **The server requires an explicit UTC
144
+ offset and returns a 422 (`RelayGrid::ValidationError`) on a naive or
145
+ malformed timestamp** — it never guesses what timezone you meant, so
146
+ `"2026-09-01T09:00:00"` (no offset) is rejected rather than silently treated
147
+ as UTC. A bare `Date` raises `ArgumentError` locally, before any request is
148
+ made — a date has no instant, so there's no correct offset to send.
149
+
150
+ The message is created immediately with status-`scheduled` deliveries, and
151
+ returns a `201` right away; nothing has reached a channel provider yet. A
152
+ scheduled send can still end in `delivery.failed` at dispatch time if the
153
+ account's monthly quota is spent by then — the `201` at schedule time is not
154
+ a guarantee of delivery, only that the schedule was accepted.
155
+
156
+ The push token, when present, is unaffected by scheduling: it authenticates
157
+ the recipient's device to subscribe *now*, independent of when the message
158
+ actually dispatches. `push?`/`push_token` work exactly as they do for an
159
+ immediate send, because the server reads the template's live channels, not
160
+ the deliveries.
161
+
162
+ Withdraw a scheduled send before it dispatches:
163
+
164
+ ```ruby
165
+ RelayGrid.client.messages.cancel_schedule(message_id)
166
+ ```
167
+
168
+ Only pending messages can be withdrawn. Once dispatch has claimed the row,
169
+ sends may already be going out, and the call raises `ValidationError` (422) —
170
+ there is a small window where a well-timed cancel can lose the race to the
171
+ dispatch sweep.
172
+
128
173
  ### The push token
129
174
 
130
175
  When the template has a live push channel, the send response carries a token
@@ -165,6 +210,12 @@ It returns once every delivery is **terminal** — successful or not — so chec
165
210
  `success?` yourself rather than treating a return as a win. A delivery the server
166
211
  is still retrying reads `retrying`, which is why `failed` means final.
167
212
 
213
+ For a scheduled send (`result.scheduled?`), this returns `deliveries`
214
+ immediately — still reading `status: "scheduled"` — instead of polling. A
215
+ `scheduled` delivery is never terminal, since nothing has been handed to a
216
+ provider yet, so polling it would otherwise just block for the full `timeout`
217
+ waiting on a state change that isn't coming until dispatch.
218
+
168
219
  > **This blocks the calling thread.** Use it in a background job, a rake task, or
169
220
  > a script — never inside a web request, where it would pin a request thread for
170
221
  > up to `timeout` seconds. In a request, return `delivery_ids` and let the
@@ -15,11 +15,16 @@ module RelayGrid
15
15
  FAILED = "failed"
16
16
  BOUNCED = "bounced"
17
17
  OPENED = "opened"
18
+ SCHEDULED = "scheduled"
19
+ CANCELED = "canceled"
18
20
 
19
21
  SUCCESS_STATUSES = [DELIVERED, OPENED].freeze
20
22
 
21
- # States the server will not move away from on its own.
22
- TERMINAL_STATUSES = (SUCCESS_STATUSES + [FAILED, BOUNCED]).freeze
23
+ # States the server will not move away from on its own. `scheduled` is
24
+ # deliberately absent -- only the sweep moves it, which can be well past
25
+ # any reasonable poll timeout. `canceled` is terminal; the server never
26
+ # revives a canceled delivery.
27
+ TERMINAL_STATUSES = (SUCCESS_STATUSES + [FAILED, BOUNCED, CANCELED]).freeze
23
28
 
24
29
  attr_reader :id, :message_id, :status, :channel_id, :channel_name, :channel_type,
25
30
  :error_message, :friendly_error_message,
@@ -79,6 +84,14 @@ module RelayGrid
79
84
  status == OPENED
80
85
  end
81
86
 
87
+ def scheduled?
88
+ status == SCHEDULED
89
+ end
90
+
91
+ def canceled?
92
+ status == CANCELED
93
+ end
94
+
82
95
  # Reached a state the server considers final and successful.
83
96
  def success?
84
97
  SUCCESS_STATUSES.include?(status)
@@ -58,6 +58,18 @@ module RelayGrid
58
58
  def render(id)
59
59
  @client.get("/api/v1/messages/#{id}/render_message")
60
60
  end
61
+
62
+ # Withdraw a scheduled send before it dispatches. Only pending messages
63
+ # can be withdrawn -- once dispatch has claimed the row, channel sends
64
+ # may already be going out, and there's nothing left to cancel.
65
+ #
66
+ # @return [Hash] the message, with `schedule_canceled_reason: "requested"`
67
+ # @raise [RelayGrid::ValidationError] the message already dispatched or
68
+ # was already canceled (the sweep won the race)
69
+ # @raise [RelayGrid::NotFoundError] no such message
70
+ def cancel_schedule(id)
71
+ @client.delete("/api/v1/messages/#{id}/schedule")["message"]
72
+ end
61
73
  end
62
74
  end
63
75
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
4
+ require "time"
5
+
3
6
  module RelayGrid
4
7
  module Resources
5
8
  # Sending. The developer contract the gem exists for: a user, a template
@@ -28,11 +31,19 @@ module RelayGrid
28
31
  # Passing an empty string removes the contact.
29
32
  # @param template [String] the message template's name
30
33
  # @param attributes [Hash] values for the template's `{{placeholders}}`
34
+ # @param scheduled_at [Time, DateTime, ActiveSupport::TimeWithZone, String, nil]
35
+ # when to deliver instead of immediately. A `Time`/`DateTime`/`TimeWithZone`
36
+ # is serialized with `.iso8601`, which always emits a UTC offset; a
37
+ # `String` is passed through untouched for the server to validate. The
38
+ # server rejects a naive or malformed timestamp with a 422 -- it never
39
+ # guesses a timezone. `nil` (the default) sends immediately.
31
40
  # @return [RelayGrid::SendResult]
32
41
  #
33
42
  # @raise [RelayGrid::TemplateNotFoundError] no such template in the account
34
43
  # @raise [RelayGrid::LimitExceededError] the monthly notification limit is spent
35
- # @raise [RelayGrid::ValidationError] the payload was rejected
44
+ # @raise [RelayGrid::ValidationError] the payload was rejected, e.g. a
45
+ # `scheduled_at` with no UTC offset
46
+ # @raise [ArgumentError] `scheduled_at` is a bare `Date`, which has no instant
36
47
  #
37
48
  # @example
38
49
  # result = RelayGrid.client.notify(
@@ -41,18 +52,45 @@ module RelayGrid
41
52
  # attributes: { order_number: "1042", eta: "tomorrow" }
42
53
  # )
43
54
  # result.delivery_ids # => [201, 202]
44
- def notify(user:, template:, attributes: {})
55
+ #
56
+ # @example Scheduling a send
57
+ # RelayGrid.client.notify(
58
+ # user: "user-123", template: "order_shipped",
59
+ # scheduled_at: Time.now + 3600 # or "2026-09-01T09:00:00-07:00"
60
+ # )
61
+ def notify(user:, template:, attributes: {}, scheduled_at: nil)
45
62
  message = {
46
63
  message_template_name: template.to_s,
47
64
  message_attributes_attributes: format_attributes(attributes),
48
65
  }
49
66
  message.merge!(recipient_params(user))
67
+ message[:scheduled_at] = serialize_scheduled_at(scheduled_at) unless scheduled_at.nil?
50
68
 
51
69
  SendResult.new(@client.post("/api/v1/messages", body: { message: message }), client: @client)
52
70
  end
53
71
 
54
72
  private
55
73
 
74
+ # A `Date` has a day but no instant, so there's no correct offset to emit
75
+ # -- `DateTime` (which `Date` is a superclass of) is fine, only a bare
76
+ # `Date` is rejected. Everything else that responds to `iso8601` (`Time`,
77
+ # `DateTime`, `ActiveSupport::TimeWithZone`) is serialized with it; a
78
+ # `String` passes through for the server to validate.
79
+ def serialize_scheduled_at(value)
80
+ return value if value.is_a?(String)
81
+
82
+ if value.is_a?(Date) && !value.is_a?(DateTime)
83
+ raise ArgumentError, "scheduled_at must be a Time, DateTime, or TimeWithZone -- " \
84
+ "a Date has no instant, so there is no offset to serialize"
85
+ end
86
+
87
+ unless value.respond_to?(:iso8601)
88
+ raise ArgumentError, "scheduled_at must be a Time, DateTime, TimeWithZone, or ISO 8601 String"
89
+ end
90
+
91
+ value.iso8601
92
+ end
93
+
56
94
  # A Hash upserts the recipient server-side (one round trip instead of
57
95
  # registering the user first); a bare id is a strict lookup.
58
96
  def recipient_params(user)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module RelayGrid
4
6
  # What `RelayGrid.client.notify` hands back: the message that was created, the
5
7
  # deliveries queued for it, and -- when the template has a live push channel
@@ -7,7 +9,7 @@ module RelayGrid
7
9
  # realtime channel.
8
10
  class SendResult
9
11
  attr_reader :message_id, :push_token, :push_token_expires_at, :deliveries,
10
- :rendered_subject, :rendered_body, :raw
12
+ :rendered_subject, :rendered_body, :scheduled_at, :raw
11
13
 
12
14
  def initialize(body, client:)
13
15
  @raw = body
@@ -17,6 +19,7 @@ module RelayGrid
17
19
  @message_id = message["id"]
18
20
  @rendered_subject = message["rendered_subject"]
19
21
  @rendered_body = message["rendered_body"]
22
+ @scheduled_at = parse_time(message["scheduled_at"])
20
23
 
21
24
  @push_token = body["push_token"]
22
25
  expires_in = body["push_token_expires_in"]
@@ -43,16 +46,28 @@ module RelayGrid
43
46
  deliveries.find { |delivery| delivery.channel_type == channel_type.to_s }
44
47
  end
45
48
 
49
+ # True when this send was scheduled for later rather than sent immediately.
50
+ def scheduled?
51
+ !scheduled_at.nil?
52
+ end
53
+
46
54
  # Poll until every delivery reaches a terminal state, or `timeout` seconds
47
55
  # elapse. A delivery the server is still retrying reads `retrying`.
48
56
  #
57
+ # A `scheduled` delivery is never terminal -- nothing has been handed to a
58
+ # provider yet, so there is nothing to settle -- and polling it would just
59
+ # burn the full `timeout` waiting for a state change that isn't coming
60
+ # until dispatch. When `scheduled?` is true, this returns `deliveries`
61
+ # (each still reading `status: "scheduled"`) immediately without calling
62
+ # the API at all.
63
+ #
49
64
  # @param timeout [Numeric] seconds to keep polling
50
65
  # @param interval [Numeric] seconds between polls
51
66
  # @param raise_on_timeout [Boolean] raise TimeoutWaitingForDeliveries
52
67
  # instead of returning the last-seen deliveries
53
68
  # @return [Array<RelayGrid::Delivery>] deliveries as last seen
54
69
  def wait_for_deliveries(timeout: 30, interval: 2, raise_on_timeout: false)
55
- return deliveries if deliveries.empty?
70
+ return deliveries if deliveries.empty? || scheduled?
56
71
 
57
72
  @client.deliveries.wait_for(
58
73
  delivery_ids,
@@ -70,5 +85,15 @@ module RelayGrid
70
85
  "#<RelayGrid::SendResult message_id: #{message_id.inspect}, " \
71
86
  "delivery_ids: #{delivery_ids.inspect}, push: #{push?}>"
72
87
  end
88
+
89
+ private
90
+
91
+ def parse_time(value)
92
+ return nil if value.nil? || value.to_s.empty?
93
+
94
+ Time.parse(value.to_s)
95
+ rescue ArgumentError
96
+ nil
97
+ end
73
98
  end
74
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RelayGrid
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaygrid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Zand
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.6'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.1'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.1'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rspec
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -153,13 +167,13 @@ files:
153
167
  - lib/relaygrid/webhooks.rb
154
168
  - lib/relaygrid/websocket_client.rb
155
169
  - sig/relaygrid.rbs
156
- homepage: https://github.com/alizand1992/relaygrid-gem
170
+ homepage: https://git.relaygrid.dev/relaygrid/relaygrid-gem
157
171
  licenses:
158
172
  - MIT
159
173
  metadata:
160
- homepage_uri: https://github.com/alizand1992/relaygrid-gem
161
- source_code_uri: https://github.com/alizand1992/relaygrid-gem
162
- changelog_uri: https://github.com/alizand1992/relaygrid-gem/blob/master/CHANGELOG.md
174
+ homepage_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem
175
+ source_code_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem
176
+ changelog_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem/-/blob/master/CHANGELOG.md
163
177
  rubygems_mfa_required: 'true'
164
178
  rdoc_options: []
165
179
  require_paths: