parse-stack-next 4.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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,975 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../query.rb"
5
+ require_relative "../client.rb"
6
+ require "active_model/serializers/json"
7
+
8
+ module Parse
9
+ # This class represents the API to send push notification to devices that are
10
+ # available in the Installation table. Push notifications are implemented
11
+ # through the `Parse::Push` class. To send push notifications through the
12
+ # REST API, you must enable `REST push enabled?` option in the `Push
13
+ # Notification Settings` section of the `Settings` page in your Parse
14
+ # application. Push notifications targeting uses the Installation Parse
15
+ # class to determine which devices receive the notification. You can provide
16
+ # any query constraint, similar to using `Parse::Query`, in order to target
17
+ # the specific set of devices you want given the columns you have configured
18
+ # in your `Installation` class. The `Parse::Push` class supports many other
19
+ # options not listed here.
20
+ #
21
+ # @example Traditional API
22
+ # # simple channel push (targeted, sends without opt-in)
23
+ # push = Parse::Push.new
24
+ # push.channels = ["addicted2salsa"]
25
+ # push.send "You are subscribed to Addicted2Salsa!"
26
+ #
27
+ # # advanced targeting
28
+ # push = Parse::Push.new({..where query constraints..})
29
+ # push.where :device_type.in => ['ios','android'], :location.near => some_geopoint
30
+ # push.alert = "Hello World!"
31
+ # push.sound = "soundfile.caf"
32
+ # push.data = { uri: "app://deep_link_path" }
33
+ # push.send
34
+ #
35
+ # # broadcast to every Installation (requires explicit opt-in)
36
+ # Parse::Push.new.broadcast!.send("Hello World!")
37
+ # # or set process-wide: Parse::Push.allow_broadcast = true
38
+ #
39
+ # @example Builder Pattern API (Fluent Interface)
40
+ # # Simple channel push with builder pattern
41
+ # Parse::Push.new
42
+ # .to_channel("news")
43
+ # .with_alert("Breaking news!")
44
+ # .send!
45
+ #
46
+ # # Rich push with scheduling
47
+ # Parse::Push.new
48
+ # .to_channels("sports", "updates")
49
+ # .with_title("Game Alert")
50
+ # .with_body("Your team is playing now!")
51
+ # .with_badge(1)
52
+ # .with_sound("alert.caf")
53
+ # .with_data(game_id: "12345")
54
+ # .schedule(1.hour.from_now)
55
+ # .expires_in(3600)
56
+ # .send!
57
+ #
58
+ # # Using class method shortcut
59
+ # Parse::Push.to_channel("alerts")
60
+ # .with_alert("Important update")
61
+ # .send!
62
+ #
63
+ # # Using query block for advanced targeting
64
+ # Parse::Push.new
65
+ # .to_query { |q| q.where(:device_type => "ios", :app_version.gte => "2.0") }
66
+ # .with_alert("iOS 2.0+ users only")
67
+ # .send!
68
+ #
69
+ class Push
70
+ include Client::Connectable
71
+
72
+ # Raised when a {Parse::Push} would broadcast to every Installation
73
+ # because no `where` constraints and no `channels` are set, and the
74
+ # caller did not explicitly opt in via {Parse::Push.allow_broadcast}
75
+ # or per-instance {#broadcast!}.
76
+ #
77
+ # This is a fail-closed guard against the +to_audience+ /
78
+ # +to_audience_id+ class of footguns where a typo, deleted audience,
79
+ # or unset-param silently degrades a targeted push into a global one.
80
+ class BroadcastNotAllowed < StandardError; end
81
+
82
+ # Raised when {#to_audience} or {#to_audience_id} cannot resolve the
83
+ # requested audience. Previously these methods warned and returned
84
+ # +self+, which let the subsequent +send!+ silently broadcast to every
85
+ # Installation. They now raise so typos and renames surface at the
86
+ # call site instead.
87
+ class AudienceNotFound < ArgumentError; end
88
+
89
+ # @!attribute [rw] allow_broadcast
90
+ # Whether {Parse::Push} permits an unconstrained push (no `where`,
91
+ # no `channels`) to broadcast to every Installation. Defaults to
92
+ # +false+ — sending an unconstrained push raises {BroadcastNotAllowed}.
93
+ #
94
+ # Set to +true+ at boot for apps that legitimately broadcast (e.g.,
95
+ # `Parse::Push.allow_broadcast = true`). Or opt in per-instance with
96
+ # {#broadcast!}, which is auditable in code review.
97
+ # @return [Boolean]
98
+ class << self
99
+ attr_accessor :allow_broadcast
100
+ end
101
+ self.allow_broadcast = false
102
+
103
+ # Device types that support push notifications.
104
+ # These are the device types that Parse Server has push adapters for.
105
+ # @see https://docs.parseplatform.org/parse-server/guide/#push-notifications
106
+ SUPPORTED_PUSH_DEVICE_TYPES = %w[ios android osx tvos watchos web expo].freeze
107
+
108
+ # Device types that are known but may not have push support configured.
109
+ # These will generate warnings when targeted.
110
+ UNSUPPORTED_PUSH_DEVICE_TYPES = %w[win other unknown unsupported].freeze
111
+
112
+ # @!attribute [rw] query
113
+ # Sending a push notification is done by performing a query against the Installation
114
+ # collection with a Parse::Query. This query contains the constraints that will be
115
+ # sent to Parse with the push payload.
116
+ # @return [Parse::Query] the query containing Installation constraints.
117
+
118
+ # @!attribute [rw] alert
119
+ # @return [String]
120
+ # @!attribute [rw] badge
121
+ # @return [Integer]
122
+ # @!attribute [rw] sound
123
+ # @return [String] the name of the sound file
124
+ # @!attribute [rw] title
125
+ # @return [String]
126
+ # @!attribute [rw] data
127
+ # @return [Hash] specific payload data.
128
+ # @!attribute [rw] expiration_time
129
+ # @return [Parse::Date]
130
+ # @!attribute [rw] expiration_interval
131
+ # @return [Integer]
132
+ # @!attribute [rw] push_time
133
+ # @return [Parse::Date]
134
+ # @!attribute [rw] channels
135
+ # @return [Array] an array of strings for subscribed channels.
136
+ # @!attribute [rw] content_available
137
+ # @return [Boolean] whether this is a silent push (iOS content-available).
138
+ # @!attribute [rw] mutable_content
139
+ # @return [Boolean] whether this notification can be modified by a service extension (iOS).
140
+ # @!attribute [rw] category
141
+ # @return [String] the notification category for action buttons (iOS).
142
+ # @!attribute [rw] image_url
143
+ # @return [String] URL for an image attachment (requires mutable-content).
144
+ # @!attribute [rw] localized_alerts
145
+ # @return [Hash] language-specific alert messages (e.g., {"en" => "Hello", "fr" => "Bonjour"})
146
+ # @!attribute [rw] localized_titles
147
+ # @return [Hash] language-specific titles (e.g., {"en" => "Welcome", "fr" => "Bienvenue"})
148
+ attr_writer :query
149
+ attr_reader :channels, :data
150
+ attr_accessor :alert, :badge, :sound, :title,
151
+ :expiration_time, :expiration_interval, :push_time,
152
+ :content_available, :mutable_content, :category, :image_url,
153
+ :localized_alerts, :localized_titles
154
+
155
+ alias_method :message, :alert
156
+ alias_method :message=, :alert=
157
+
158
+ # Send a push notification using a push notification hash
159
+ # @param payload [Hash] a push notification hash payload
160
+ def self.send(payload)
161
+ client.push payload.as_json
162
+ end
163
+
164
+ # Create a new Push targeting a specific channel.
165
+ # @param channel [String] the channel name to target
166
+ # @return [Parse::Push] a new Push instance for chaining
167
+ # @example
168
+ # Parse::Push.to_channel("news").with_alert("Hello!").send!
169
+ def self.to_channel(channel)
170
+ new.to_channel(channel)
171
+ end
172
+
173
+ # Create a new Push targeting multiple channels.
174
+ # @param channels [Array<String>] the channel names to target
175
+ # @return [Parse::Push] a new Push instance for chaining
176
+ # @example
177
+ # Parse::Push.to_channels("news", "sports").with_alert("Update!").send!
178
+ def self.to_channels(*channels)
179
+ new.to_channels(*channels)
180
+ end
181
+
182
+ # List all available channels from the Installation collection.
183
+ # This is a convenience method that delegates to {Installation.all_channels}.
184
+ # @return [Array<String>] array of channel names
185
+ # @example
186
+ # available_channels = Parse::Push.channels
187
+ # # => ["news", "sports", "weather"]
188
+ def self.channels
189
+ Parse::Installation.all_channels
190
+ end
191
+
192
+ # Create a new Push targeting a specific user.
193
+ # @param user [Parse::User, Hash, String] the user to target
194
+ # @return [Parse::Push] a new Push instance for chaining
195
+ # @example
196
+ # Parse::Push.to_user(current_user).with_alert("Hello!").send!
197
+ def self.to_user(user)
198
+ new.to_user(user)
199
+ end
200
+
201
+ # Create a new Push targeting a user by their objectId.
202
+ # @param user_id [String] the objectId of the user to target
203
+ # @return [Parse::Push] a new Push instance for chaining
204
+ # @example
205
+ # Parse::Push.to_user_id("abc123").with_alert("Hello!").send!
206
+ def self.to_user_id(user_id)
207
+ new.to_user_id(user_id)
208
+ end
209
+
210
+ # Create a new Push targeting multiple users.
211
+ # @param users [Array<Parse::User, Hash, String>] the users to target
212
+ # @return [Parse::Push] a new Push instance for chaining
213
+ # @example
214
+ # Parse::Push.to_users(user1, user2).with_alert("Group message!").send!
215
+ def self.to_users(*users)
216
+ new.to_users(*users)
217
+ end
218
+
219
+ # Create a new Push targeting a specific installation.
220
+ # @param installation [Parse::Installation, Hash, String] the installation to target
221
+ # @return [Parse::Push] a new Push instance for chaining
222
+ # @example
223
+ # Parse::Push.to_installation(device).with_alert("Hello!").send!
224
+ def self.to_installation(installation)
225
+ new.to_installation(installation)
226
+ end
227
+
228
+ # Create a new Push targeting an installation by its objectId.
229
+ # @param installation_id [String] the objectId of the installation to target
230
+ # @return [Parse::Push] a new Push instance for chaining
231
+ # @example
232
+ # Parse::Push.to_installation_id("abc123").with_alert("Hello!").send!
233
+ def self.to_installation_id(installation_id)
234
+ new.to_installation_id(installation_id)
235
+ end
236
+
237
+ # Create a new Push targeting multiple installations.
238
+ # @param installations [Array<Parse::Installation, Hash, String>] the installations to target
239
+ # @return [Parse::Push] a new Push instance for chaining
240
+ # @example
241
+ # Parse::Push.to_installations(device1, device2).with_alert("Hello!").send!
242
+ def self.to_installations(*installations)
243
+ new.to_installations(*installations)
244
+ end
245
+
246
+ # Initialize a new push notification request.
247
+ # @param constraints [Hash] a set of query constraints
248
+ def initialize(constraints = {})
249
+ self.where constraints
250
+ end
251
+
252
+ def query
253
+ @query ||= Parse::Query.new(Parse::Model::CLASS_INSTALLATION)
254
+ end
255
+
256
+ # Set a hash of conditions for this push query.
257
+ # @return [Parse::Query]
258
+ def where=(where_clauses)
259
+ query.where where_clauses
260
+ end
261
+
262
+ # Apply a set of constraints.
263
+ # @param constraints [Hash] the set of {Parse::Query} cosntraints
264
+ # @return [Hash] if no constraints were passed, returns a compiled query.
265
+ # @return [Parse::Query] if constraints were passed, returns the chainable query.
266
+ def where(constraints = nil)
267
+ return query.compile_where unless constraints.is_a?(Hash)
268
+ query.where constraints
269
+ query
270
+ end
271
+
272
+ def channels=(list)
273
+ @channels = Array.wrap(list)
274
+ end
275
+
276
+ # Check if this push has content-available set (silent push).
277
+ # @return [Boolean] true if content-available is enabled
278
+ def content_available?
279
+ @content_available == true
280
+ end
281
+
282
+ # Check if this push has mutable-content set (rich notifications).
283
+ # @return [Boolean] true if mutable-content is enabled
284
+ def mutable_content?
285
+ @mutable_content == true
286
+ end
287
+
288
+ def data=(h)
289
+ if h.is_a?(String)
290
+ @alert = h
291
+ else
292
+ @data = h.symbolize_keys
293
+ end
294
+ end
295
+
296
+ # @return [Hash] a JSON encoded hash.
297
+ def as_json(*args)
298
+ payload.as_json
299
+ end
300
+
301
+ # @return [String] a JSON encoded string.
302
+ def to_json(*args)
303
+ as_json.to_json
304
+ end
305
+
306
+ # This method takes all the parameters of the instance and creates a proper
307
+ # hash structure, required by Parse, in order to process the push notification.
308
+ # @return [Hash] the prepared push payload to be used in the request.
309
+ def payload
310
+ msg = {
311
+ data: {
312
+ alert: alert,
313
+ badge: badge || "Increment",
314
+ },
315
+ }
316
+ msg[:data][:sound] = sound if sound.present?
317
+ msg[:data][:title] = title if title.present?
318
+ msg[:data][:"content-available"] = 1 if content_available?
319
+ msg[:data][:"mutable-content"] = 1 if mutable_content?
320
+ msg[:data][:category] = @category if @category.present?
321
+ msg[:data][:image] = @image_url if @image_url.present?
322
+
323
+ # Add localized alerts (e.g., "alert-en", "alert-fr")
324
+ if @localized_alerts.is_a?(Hash)
325
+ @localized_alerts.each do |lang, text|
326
+ msg[:data][:"alert-#{lang}"] = text
327
+ end
328
+ end
329
+
330
+ # Add localized titles (e.g., "title-en", "title-fr")
331
+ if @localized_titles.is_a?(Hash)
332
+ @localized_titles.each do |lang, text|
333
+ msg[:data][:"title-#{lang}"] = text
334
+ end
335
+ end
336
+
337
+ msg[:data].merge! @data if @data.is_a?(Hash)
338
+
339
+ if @expiration_time.present?
340
+ msg[:expiration_time] = @expiration_time.respond_to?(:iso8601) ? @expiration_time.iso8601(3) : @expiration_time
341
+ end
342
+ if @push_time.present?
343
+ msg[:push_time] = @push_time.respond_to?(:iso8601) ? @push_time.iso8601(3) : @push_time
344
+ end
345
+
346
+ if @expiration_interval.is_a?(Numeric)
347
+ msg[:expiration_interval] = @expiration_interval.to_i
348
+ end
349
+
350
+ if query.where.present?
351
+ q = @query.dup
352
+ if @channels.is_a?(Array) && @channels.empty? == false
353
+ q.where :channels.in => @channels
354
+ end
355
+ msg[:where] = q.compile_where unless q.where.empty?
356
+ elsif @channels.is_a?(Array) && @channels.empty? == false
357
+ msg[:channels] = @channels
358
+ end
359
+ msg
360
+ end
361
+
362
+ # helper method to send a message
363
+ # @param message [String] the message to send
364
+ # @raise [BroadcastNotAllowed] if the push has no `where` constraints
365
+ # and no `channels`, and neither {Parse::Push.allow_broadcast} nor
366
+ # per-instance {#broadcast!} was set.
367
+ def send(message = nil)
368
+ @alert = message if message.is_a?(String)
369
+ @data = message if message.is_a?(Hash)
370
+ assert_broadcast_allowed!
371
+ client.push(payload.as_json)
372
+ end
373
+
374
+ # Opt this specific push in to broadcasting to every Installation.
375
+ # Use when you legitimately want a global push and have not set the
376
+ # process-wide {Parse::Push.allow_broadcast}. The explicit call site
377
+ # is the audit trail.
378
+ # @return [self] for chaining
379
+ # @example
380
+ # Parse::Push.new.broadcast!.with_alert("Maintenance window").send!
381
+ def broadcast!
382
+ @broadcast_allowed = true
383
+ self
384
+ end
385
+
386
+ # @return [Boolean] true when broadcasting is allowed for this push,
387
+ # either via the class-level {Parse::Push.allow_broadcast} flag or
388
+ # the per-instance {#broadcast!} opt-in.
389
+ def broadcast_allowed?
390
+ @broadcast_allowed == true || self.class.allow_broadcast == true
391
+ end
392
+
393
+ private
394
+
395
+ # Raise {BroadcastNotAllowed} when the assembled payload would
396
+ # broadcast (no `where`, no `channels`) and neither the class-level
397
+ # nor the per-instance opt-in is set. Called from {#send} and {#send!}.
398
+ def assert_broadcast_allowed!
399
+ return if broadcast_allowed?
400
+ compiled = payload
401
+ has_where = compiled[:where].is_a?(Hash) && !compiled[:where].empty?
402
+ has_channels = compiled[:channels].is_a?(Array) && !compiled[:channels].empty?
403
+ return if has_where || has_channels
404
+ raise BroadcastNotAllowed,
405
+ "Refusing to broadcast push to every Installation: no `where` " \
406
+ "constraints and no `channels` are set. Add targeting (channels, " \
407
+ "to_audience, to_user, to_query), or opt in explicitly via " \
408
+ "Parse::Push.allow_broadcast = true or per-instance #broadcast!."
409
+ end
410
+
411
+ public
412
+
413
+ # =========================================================================
414
+ # Builder Pattern Methods (Fluent Interface)
415
+ # =========================================================================
416
+
417
+ # Target a specific channel for this push notification.
418
+ # @param channel [String] the channel name to target
419
+ # @return [self] returns self for method chaining
420
+ # @example
421
+ # push.to_channel("news").with_alert("Update!").send!
422
+ def to_channel(channel)
423
+ self.channels = [channel]
424
+ self
425
+ end
426
+
427
+ # Target multiple channels for this push notification.
428
+ # @param channels [Array<String>] the channel names to target
429
+ # @return [self] returns self for method chaining
430
+ # @example
431
+ # push.to_channels("news", "sports").with_alert("Update!").send!
432
+ def to_channels(*channels)
433
+ self.channels = channels.flatten
434
+ self
435
+ end
436
+
437
+ # Configure the push query using a block.
438
+ # The block receives the query object for adding constraints.
439
+ # @yield [Parse::Query] the Installation query to configure
440
+ # @return [self] returns self for method chaining
441
+ # @example
442
+ # push.to_query { |q| q.where(:device_type => "ios") }.send!
443
+ def to_query
444
+ yield query if block_given?
445
+ self
446
+ end
447
+
448
+ # Set the alert message for this push notification.
449
+ # @param message [String] the alert message
450
+ # @return [self] returns self for method chaining
451
+ # @example
452
+ # push.with_alert("Hello World!").send!
453
+ def with_alert(message)
454
+ self.alert = message
455
+ self
456
+ end
457
+
458
+ # Alias for {#with_alert} - sets the body text of the notification.
459
+ # @param body [String] the body/alert message
460
+ # @return [self] returns self for method chaining
461
+ # @see #with_alert
462
+ def with_body(body)
463
+ with_alert(body)
464
+ end
465
+
466
+ # Set the title for this push notification (appears above the alert).
467
+ # @param title [String] the notification title
468
+ # @return [self] returns self for method chaining
469
+ # @example
470
+ # push.with_title("News").with_body("Article published").send!
471
+ def with_title(title)
472
+ self.title = title
473
+ self
474
+ end
475
+
476
+ # Set the badge number for this push notification.
477
+ # @param count [Integer, String] the badge count, or "Increment" to increment
478
+ # @return [self] returns self for method chaining
479
+ # @example
480
+ # push.with_badge(5).send! # Set to 5
481
+ # push.with_badge(0).send! # Clear badge
482
+ def with_badge(count)
483
+ self.badge = count
484
+ self
485
+ end
486
+
487
+ # Set the sound file for this push notification.
488
+ # @param sound_name [String] the name of the sound file
489
+ # @return [self] returns self for method chaining
490
+ # @example
491
+ # push.with_sound("notification.caf").send!
492
+ def with_sound(sound_name)
493
+ self.sound = sound_name
494
+ self
495
+ end
496
+
497
+ # Set custom data payload for this push notification.
498
+ # @param hash [Hash] custom key-value pairs to include in the payload
499
+ # @return [self] returns self for method chaining
500
+ # @example
501
+ # push.with_data(article_id: "123", action: "open").send!
502
+ def with_data(hash)
503
+ @data ||= {}
504
+ @data.merge!(hash.symbolize_keys)
505
+ self
506
+ end
507
+
508
+ # Schedule the push notification for a future time.
509
+ # @param time [Time, DateTime, String] when to send the push
510
+ # @return [self] returns self for method chaining
511
+ # @example
512
+ # push.schedule(1.hour.from_now).send!
513
+ # push.schedule(Time.new(2025, 12, 25, 9, 0, 0)).send!
514
+ def schedule(time)
515
+ self.push_time = time
516
+ self
517
+ end
518
+
519
+ # Set the expiration time for this push notification.
520
+ # The push will not be delivered after this time.
521
+ # @param time [Time, DateTime, String] when the push expires
522
+ # @return [self] returns self for method chaining
523
+ # @example
524
+ # push.expires_at(2.hours.from_now).send!
525
+ def expires_at(time)
526
+ self.expiration_time = time
527
+ self
528
+ end
529
+
530
+ # Set the expiration interval for this push notification.
531
+ # The push will expire after this many seconds from now.
532
+ # @param seconds [Integer] number of seconds until expiration
533
+ # @return [self] returns self for method chaining
534
+ # @example
535
+ # push.expires_in(3600).send! # Expires in 1 hour
536
+ # push.expires_in(86400).send! # Expires in 24 hours
537
+ def expires_in(seconds)
538
+ self.expiration_interval = seconds.to_i
539
+ self
540
+ end
541
+
542
+ # Mark this as a silent push notification (iOS content-available).
543
+ # Silent pushes wake the app in the background without displaying an alert.
544
+ # @return [self] returns self for method chaining
545
+ # @example
546
+ # push.silent!.with_data(action: "sync").send!
547
+ # @see https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
548
+ def silent!
549
+ @content_available = true
550
+ self
551
+ end
552
+
553
+ # =========================================================================
554
+ # Rich Push Methods (iOS Notification Service Extension)
555
+ # =========================================================================
556
+
557
+ # Add an image attachment to the push notification.
558
+ # This automatically enables mutable-content for iOS service extension processing.
559
+ # @param url [String] the URL of the image to attach
560
+ # @return [self] returns self for method chaining
561
+ # @example
562
+ # push.with_image("https://example.com/image.jpg").with_alert("Check this out!").send!
563
+ # @see https://developer.apple.com/documentation/usernotifications/modifying_content_in_newly_delivered_notifications
564
+ def with_image(url)
565
+ @image_url = url
566
+ @mutable_content = true
567
+ self
568
+ end
569
+
570
+ # Set the notification category for action buttons (iOS).
571
+ # Categories must be registered in the app's notification settings.
572
+ # @param category_name [String] the notification category identifier
573
+ # @return [self] returns self for method chaining
574
+ # @example
575
+ # push.with_category("MESSAGE_ACTIONS").with_alert("New message").send!
576
+ # @see https://developer.apple.com/documentation/usernotifications/declaring_your_actionable_notification_types
577
+ def with_category(category_name)
578
+ @category = category_name
579
+ self
580
+ end
581
+
582
+ # Enable mutable-content for iOS notification service extension.
583
+ # This allows the notification to be modified by a service extension before display.
584
+ # @return [self] returns self for method chaining
585
+ # @example
586
+ # push.mutable!.with_data(encrypted_body: "...").send!
587
+ def mutable!
588
+ @mutable_content = true
589
+ self
590
+ end
591
+
592
+ # Send the push notification, raising an error on failure.
593
+ # This is the bang version that raises {Parse::Error} if the push fails.
594
+ # @return [Parse::Response] the response from the Parse server
595
+ # @raise [BroadcastNotAllowed] if the push has no `where` constraints
596
+ # and no `channels`, and neither {Parse::Push.allow_broadcast} nor
597
+ # per-instance {#broadcast!} was set.
598
+ # @raise [Parse::Error] if the push notification fails
599
+ # @example
600
+ # push.with_alert("Hello!").send!
601
+ def send!
602
+ assert_broadcast_allowed!
603
+ response = client.push(payload.as_json)
604
+ if response.error?
605
+ raise Parse::Error.new(response.code, response.error)
606
+ end
607
+ response
608
+ end
609
+
610
+ # =========================================================================
611
+ # Localization Methods
612
+ # =========================================================================
613
+
614
+ # Add a localized alert message for a specific language.
615
+ # Parse Server will automatically send the appropriate message based on device locale.
616
+ # @param lang [String, Symbol] the language code (e.g., :en, :fr, :es, :de)
617
+ # @param message [String] the alert message in that language
618
+ # @return [self] returns self for method chaining
619
+ # @example
620
+ # push.with_localized_alert(:en, "Hello!")
621
+ # .with_localized_alert(:fr, "Bonjour!")
622
+ # .with_localized_alert(:es, "Hola!")
623
+ # .send!
624
+ def with_localized_alert(lang, message)
625
+ @localized_alerts ||= {}
626
+ @localized_alerts[lang.to_s] = message
627
+ self
628
+ end
629
+
630
+ # Add a localized title for a specific language.
631
+ # Parse Server will automatically send the appropriate title based on device locale.
632
+ # @param lang [String, Symbol] the language code (e.g., :en, :fr, :es, :de)
633
+ # @param title [String] the title in that language
634
+ # @return [self] returns self for method chaining
635
+ # @example
636
+ # push.with_localized_title(:en, "Welcome")
637
+ # .with_localized_title(:fr, "Bienvenue")
638
+ # .with_alert("Default message")
639
+ # .send!
640
+ def with_localized_title(lang, title)
641
+ @localized_titles ||= {}
642
+ @localized_titles[lang.to_s] = title
643
+ self
644
+ end
645
+
646
+ # Set multiple localized alerts at once.
647
+ # @param translations [Hash] a hash of language codes to messages
648
+ # @return [self] returns self for method chaining
649
+ # @example
650
+ # push.with_localized_alerts(en: "Hello!", fr: "Bonjour!", es: "Hola!").send!
651
+ def with_localized_alerts(translations)
652
+ @localized_alerts ||= {}
653
+ translations.each { |lang, msg| @localized_alerts[lang.to_s] = msg }
654
+ self
655
+ end
656
+
657
+ # Set multiple localized titles at once.
658
+ # @param translations [Hash] a hash of language codes to titles
659
+ # @return [self] returns self for method chaining
660
+ # @example
661
+ # push.with_localized_titles(en: "Welcome", fr: "Bienvenue").send!
662
+ def with_localized_titles(translations)
663
+ @localized_titles ||= {}
664
+ translations.each { |lang, title| @localized_titles[lang.to_s] = title }
665
+ self
666
+ end
667
+
668
+ # =========================================================================
669
+ # Badge Increment Methods
670
+ # =========================================================================
671
+
672
+ # Increment the badge count instead of setting an absolute value.
673
+ # This is useful when you want to add to the existing badge rather than replace it.
674
+ # @param amount [Integer] the amount to increment by (default: 1)
675
+ # @return [self] returns self for method chaining
676
+ # @example
677
+ # push.increment_badge.with_alert("New message!").send! # +1
678
+ # push.increment_badge(5).with_alert("5 new items!").send! # +5
679
+ def increment_badge(amount = 1)
680
+ if amount == 1
681
+ self.badge = "Increment"
682
+ else
683
+ self.badge = { "__op" => "Increment", "amount" => amount.to_i }
684
+ end
685
+ self
686
+ end
687
+
688
+ # Clear the badge (set to 0).
689
+ # @return [self] returns self for method chaining
690
+ # @example
691
+ # push.clear_badge.silent!.send! # Clear badge silently
692
+ def clear_badge
693
+ self.badge = 0
694
+ self
695
+ end
696
+
697
+ # =========================================================================
698
+ # Audience Targeting Methods
699
+ # =========================================================================
700
+
701
+ # Target a saved audience by name.
702
+ # Audiences are pre-defined in the _Audience collection and can be reused.
703
+ # Uses caching by default for better performance.
704
+ #
705
+ # @param audience_name [String] the name of the saved audience
706
+ # @param cache [Boolean] whether to use audience cache (default: true)
707
+ # @return [self] returns self for method chaining
708
+ # @raise [AudienceNotFound] if no audience exists with the given name.
709
+ # Previously this method emitted a `warn` and returned `self`, which
710
+ # let the subsequent `send!` broadcast to every Installation. The
711
+ # raise makes typos and renames loud at the call site.
712
+ # @example
713
+ # push.to_audience("VIP Users").with_alert("Exclusive offer!").send!
714
+ # @note The audience must exist in the _Audience collection
715
+ def to_audience(audience_name, cache: true)
716
+ # Use cached audience lookup for better performance
717
+ audience = Parse::Audience.find_by_name(audience_name, cache: cache)
718
+
719
+ if audience.nil?
720
+ raise AudienceNotFound,
721
+ "Audience '#{audience_name}' not found in _Audience collection"
722
+ end
723
+
724
+ if audience.query_constraint.present?
725
+ # Merge the audience's query constraints into our query
726
+ audience.query_constraint.each do |key, value|
727
+ query.where(key.to_sym => value)
728
+ end
729
+ end
730
+ self
731
+ end
732
+
733
+ # Target a saved audience by its object ID.
734
+ # @param audience_id [String] the objectId of the saved audience
735
+ # @return [self] returns self for method chaining
736
+ # @raise [AudienceNotFound] if no audience exists with the given id.
737
+ # @example
738
+ # push.to_audience_id("abc123").with_alert("Hello!").send!
739
+ def to_audience_id(audience_id)
740
+ audience = Parse::Audience.find(audience_id)
741
+ if audience.nil?
742
+ raise AudienceNotFound,
743
+ "Audience id '#{audience_id}' not found in _Audience collection"
744
+ end
745
+ if audience.query_constraint.present?
746
+ audience.query_constraint.each do |key, value|
747
+ query.where(key.to_sym => value)
748
+ end
749
+ end
750
+ self
751
+ end
752
+
753
+ # =========================================================================
754
+ # User Targeting Methods
755
+ # =========================================================================
756
+
757
+ # Target installations belonging to a specific user (or multiple users).
758
+ # This queries the Installation collection for devices where the user pointer
759
+ # matches the given user(s).
760
+ #
761
+ # @param user [Parse::User, Hash, String, Array] the user(s) to target. Can be:
762
+ # - A Parse::User object
763
+ # - A pointer hash (e.g., { "__type" => "Pointer", "className" => "_User", "objectId" => "abc123" })
764
+ # - A user objectId string (will be converted to a pointer)
765
+ # - An array of any of the above (delegates to to_users)
766
+ # @return [self] returns self for method chaining
767
+ # @example With a Parse::User object
768
+ # user = Parse::User.find("abc123")
769
+ # Parse::Push.new.to_user(user).with_alert("Hello!").send!
770
+ #
771
+ # @example With a user objectId
772
+ # Parse::Push.new.to_user("abc123").with_alert("Hello!").send!
773
+ #
774
+ # @example With an array of users
775
+ # Parse::Push.new.to_user([user1, user2]).with_alert("Hello!").send!
776
+ #
777
+ # @example Using class method shortcut
778
+ # Parse::Push.to_user(current_user).with_alert("Welcome back!").send!
779
+ def to_user(user)
780
+ # Delegate to to_users if given an array
781
+ return to_users(user) if user.is_a?(Array)
782
+
783
+ pointer = case user
784
+ when Parse::User
785
+ user.pointer
786
+ when Hash
787
+ user
788
+ when String
789
+ Parse::Pointer.new(Parse::Model::CLASS_USER, user).to_h
790
+ else
791
+ raise ArgumentError, "Expected Parse::User, Hash, String, or Array, got #{user.class}"
792
+ end
793
+
794
+ query.where(user: pointer)
795
+ self
796
+ end
797
+
798
+ # Target installations belonging to a user by their objectId.
799
+ # This is a convenience method equivalent to to_user with a string ID.
800
+ #
801
+ # @param user_id [String] the objectId of the user to target
802
+ # @return [self] returns self for method chaining
803
+ # @example
804
+ # Parse::Push.new.to_user_id("abc123").with_alert("Hello!").send!
805
+ #
806
+ # @example Using class method shortcut
807
+ # Parse::Push.to_user_id("abc123").with_alert("You have a message").send!
808
+ def to_user_id(user_id)
809
+ pointer = Parse::Pointer.new(Parse::Model::CLASS_USER, user_id).to_h
810
+ query.where(user: pointer)
811
+ self
812
+ end
813
+
814
+ # Target installations belonging to multiple users.
815
+ # This queries the Installation collection for devices where the user pointer
816
+ # matches any of the given users.
817
+ #
818
+ # @param users [Array<Parse::User, Hash, String>] the users to target
819
+ # @return [self] returns self for method chaining
820
+ # @example
821
+ # Parse::Push.new.to_users(user1, user2, user3).with_alert("Group message!").send!
822
+ #
823
+ # @example With user IDs
824
+ # Parse::Push.new.to_users("id1", "id2", "id3").with_alert("Hello everyone!").send!
825
+ def to_users(*users)
826
+ pointers = users.flatten.map do |user|
827
+ case user
828
+ when Parse::User
829
+ user.pointer
830
+ when Hash
831
+ user
832
+ when String
833
+ Parse::Pointer.new(Parse::Model::CLASS_USER, user).to_h
834
+ else
835
+ raise ArgumentError, "Expected Parse::User, Hash, or String, got #{user.class}"
836
+ end
837
+ end
838
+
839
+ query.where(:user.in => pointers)
840
+ self
841
+ end
842
+
843
+ # =========================================================================
844
+ # Installation Targeting Methods
845
+ # =========================================================================
846
+
847
+ # Target a specific installation (or multiple installations) by object or objectId.
848
+ # This directly targets device installation(s).
849
+ #
850
+ # When given a Parse::Installation object, this method validates:
851
+ # - The installation has a device_token (raises ArgumentError if missing)
852
+ # - The device_type is supported for push (warns if unsupported)
853
+ #
854
+ # @param installation [Parse::Installation, Hash, String, Array] the installation(s) to target. Can be:
855
+ # - A Parse::Installation object
856
+ # - A hash with objectId key
857
+ # - An objectId string
858
+ # - An array of any of the above (delegates to to_installations)
859
+ # @return [self] returns self for method chaining
860
+ # @raise [ArgumentError] if installation object has no device_token
861
+ # @example With a Parse::Installation object
862
+ # device = Parse::Installation.find("abc123")
863
+ # Parse::Push.new.to_installation(device).with_alert("Hello!").send!
864
+ #
865
+ # @example With an objectId
866
+ # Parse::Push.new.to_installation("abc123").with_alert("Hello!").send!
867
+ #
868
+ # @example With an array of installations
869
+ # Parse::Push.new.to_installation([device1, device2]).with_alert("Hello!").send!
870
+ #
871
+ # @example Using class method shortcut
872
+ # Parse::Push.to_installation(device).with_alert("Device notification").send!
873
+ def to_installation(installation)
874
+ # Delegate to to_installations if given an array
875
+ return to_installations(installation) if installation.is_a?(Array)
876
+
877
+ object_id = case installation
878
+ when Parse::Installation
879
+ validate_installation_for_push!(installation)
880
+ installation.id
881
+ when Hash
882
+ installation[:objectId] || installation["objectId"] || installation[:id] || installation["id"]
883
+ when String
884
+ installation
885
+ else
886
+ raise ArgumentError, "Expected Parse::Installation, Hash, String, or Array, got #{installation.class}"
887
+ end
888
+
889
+ query.where(objectId: object_id)
890
+ self
891
+ end
892
+
893
+ # Target a specific installation by its objectId.
894
+ # This is a convenience method equivalent to to_installation with a string ID.
895
+ #
896
+ # @param installation_id [String] the objectId of the installation to target
897
+ # @return [self] returns self for method chaining
898
+ # @example
899
+ # Parse::Push.new.to_installation_id("abc123").with_alert("Hello!").send!
900
+ #
901
+ # @example Using class method shortcut
902
+ # Parse::Push.to_installation_id("abc123").with_alert("Device notification").send!
903
+ def to_installation_id(installation_id)
904
+ query.where(objectId: installation_id)
905
+ self
906
+ end
907
+
908
+ # Target multiple installations.
909
+ # This queries the Installation collection for devices matching any of the given
910
+ # installation objectIds.
911
+ #
912
+ # When given Parse::Installation objects, this method validates each:
913
+ # - The installation has a device_token (raises ArgumentError if missing)
914
+ # - The device_type is supported for push (warns if unsupported)
915
+ #
916
+ # @param installations [Array<Parse::Installation, Hash, String>] the installations to target
917
+ # @return [self] returns self for method chaining
918
+ # @raise [ArgumentError] if any installation object has no device_token
919
+ # @example
920
+ # Parse::Push.new.to_installations(device1, device2, device3).with_alert("Group notification!").send!
921
+ #
922
+ # @example With objectIds
923
+ # Parse::Push.new.to_installations("id1", "id2", "id3").with_alert("Hello devices!").send!
924
+ def to_installations(*installations)
925
+ object_ids = installations.flatten.map do |installation|
926
+ case installation
927
+ when Parse::Installation
928
+ validate_installation_for_push!(installation)
929
+ installation.id
930
+ when Hash
931
+ installation[:objectId] || installation["objectId"] || installation[:id] || installation["id"]
932
+ when String
933
+ installation
934
+ else
935
+ raise ArgumentError, "Expected Parse::Installation, Hash, or String, got #{installation.class}"
936
+ end
937
+ end
938
+
939
+ query.where(:objectId.in => object_ids)
940
+ self
941
+ end
942
+
943
+ private
944
+
945
+ # Validate that an installation can receive push notifications.
946
+ # @param installation [Parse::Installation] the installation to validate
947
+ # @raise [ArgumentError] if the installation has no device_token
948
+ # @return [void]
949
+ def validate_installation_for_push!(installation)
950
+ # Access instance variables directly to avoid triggering autofetch
951
+ device_token = installation.instance_variable_get(:@device_token)
952
+ device_type = installation.instance_variable_get(:@device_type).to_s
953
+ installation_id = installation.id
954
+
955
+ # Check for device_token - required for push delivery
956
+ if device_token.blank?
957
+ raise ArgumentError,
958
+ "Cannot send push to installation #{installation_id}: missing device_token. " \
959
+ "Push notifications require a valid device_token."
960
+ end
961
+
962
+ # Check for unsupported device types - warn but allow
963
+ if device_type.present? && !SUPPORTED_PUSH_DEVICE_TYPES.include?(device_type)
964
+ if UNSUPPORTED_PUSH_DEVICE_TYPES.include?(device_type)
965
+ warn "[Parse::Push] Warning: device_type '#{device_type}' may not be supported for push notifications. " \
966
+ "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(', ')}"
967
+ else
968
+ warn "[Parse::Push] Warning: unknown device_type '#{device_type}' for installation #{installation_id}. " \
969
+ "This device type may not receive push notifications. " \
970
+ "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(', ')}"
971
+ end
972
+ end
973
+ end
974
+ end
975
+ end