vortex-ruby-sdk 1.9.0 → 1.15.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 +4 -4
- data/.claude/implementation-guide.md +55 -29
- data/README.md +43 -11
- data/examples/basic_usage.rb +1 -1
- data/examples/rails_app.rb +6 -6
- data/examples/sinatra_app.rb +4 -4
- data/lib/vortex/client.rb +315 -29
- data/lib/vortex/rails.rb +14 -14
- data/lib/vortex/sinatra.rb +10 -10
- data/lib/vortex/types.rb +7 -5
- data/lib/vortex/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fa76d2aa711c84370eb93e563946ee45c78c12d2a5c54d6b4de13ebb49f3c6a
|
|
4
|
+
data.tar.gz: 74e291d07ca213da127b5cb35e8964e63664d101ffff8fe06aa3e586b8efc42f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef4b13b91543a141f041d2bfcc812b48e55a63998a7d8da7e445107daec73bb10e3d047532dfce015ed51a8160ddf5df28f78ca46285311fefa3a6f692ad40af
|
|
7
|
+
data.tar.gz: 9a8723995e0cdfae388688385544242e30c2c280192938ac59aebfa724f5ebf0f3508ea1eb44765c92d8ec936885bdef30ee0b828fab6ecc4c4746b29a19860f
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
**Requires:** Ruby 3.0+
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
|
+
|
|
8
9
|
From integration contract you need: API endpoint prefix, scope entity, authentication pattern
|
|
9
10
|
From discovery data you need: Ruby framework (Rails, Sinatra), database ORM, auth pattern
|
|
10
11
|
|
|
11
12
|
## Key Facts
|
|
13
|
+
|
|
12
14
|
- Framework-agnostic Ruby SDK
|
|
13
15
|
- Client-based: instantiate `Vortex::Client` class and call methods
|
|
14
16
|
- Built-in Rails and Sinatra helpers
|
|
@@ -20,11 +22,13 @@ From discovery data you need: Ruby framework (Rails, Sinatra), database ORM, aut
|
|
|
20
22
|
## Step 1: Install
|
|
21
23
|
|
|
22
24
|
Add to `Gemfile`:
|
|
25
|
+
|
|
23
26
|
```ruby
|
|
24
27
|
gem 'vortex-ruby-sdk'
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
Then install:
|
|
31
|
+
|
|
28
32
|
```bash
|
|
29
33
|
bundle install
|
|
30
34
|
```
|
|
@@ -49,6 +53,7 @@ VORTEX_API_KEY=VRTX.your-api-key-here.secret
|
|
|
49
53
|
## Step 3: Create Vortex Client
|
|
50
54
|
|
|
51
55
|
### Rails Initializer (`config/initializers/vortex.rb`):
|
|
56
|
+
|
|
52
57
|
```ruby
|
|
53
58
|
Rails.application.config.vortex = Vortex::Client.new(
|
|
54
59
|
Rails.application.credentials.vortex_api_key || ENV['VORTEX_API_KEY']
|
|
@@ -56,6 +61,7 @@ Rails.application.config.vortex = Vortex::Client.new(
|
|
|
56
61
|
```
|
|
57
62
|
|
|
58
63
|
### Rails Concern (`app/controllers/concerns/vortex_helper.rb`):
|
|
64
|
+
|
|
59
65
|
```ruby
|
|
60
66
|
module VortexHelper
|
|
61
67
|
extend ActiveSupport::Concern
|
|
@@ -71,6 +77,7 @@ end
|
|
|
71
77
|
```
|
|
72
78
|
|
|
73
79
|
### Sinatra Configuration:
|
|
80
|
+
|
|
74
81
|
```ruby
|
|
75
82
|
# app.rb or config.ru
|
|
76
83
|
require 'sinatra/base'
|
|
@@ -94,6 +101,7 @@ end
|
|
|
94
101
|
## Step 4: Extract Authenticated User
|
|
95
102
|
|
|
96
103
|
### Rails with Devise:
|
|
104
|
+
|
|
97
105
|
```ruby
|
|
98
106
|
# app/controllers/application_controller.rb
|
|
99
107
|
class ApplicationController < ActionController::API
|
|
@@ -116,6 +124,7 @@ end
|
|
|
116
124
|
```
|
|
117
125
|
|
|
118
126
|
### Rails with JWT:
|
|
127
|
+
|
|
119
128
|
```ruby
|
|
120
129
|
class ApplicationController < ActionController::API
|
|
121
130
|
before_action :authenticate_user_from_token!
|
|
@@ -147,6 +156,7 @@ end
|
|
|
147
156
|
```
|
|
148
157
|
|
|
149
158
|
### Sinatra:
|
|
159
|
+
|
|
150
160
|
```ruby
|
|
151
161
|
# app/helpers/auth_helper.rb
|
|
152
162
|
module AuthHelper
|
|
@@ -185,6 +195,7 @@ end
|
|
|
185
195
|
```
|
|
186
196
|
|
|
187
197
|
**Adapt to their patterns:**
|
|
198
|
+
|
|
188
199
|
- Match their auth mechanism (Devise, JWT, sessions)
|
|
189
200
|
- Match their user structure
|
|
190
201
|
- Match their admin detection logic
|
|
@@ -194,6 +205,7 @@ end
|
|
|
194
205
|
## Step 5: Implement JWT Generation Endpoint
|
|
195
206
|
|
|
196
207
|
### Rails (`app/controllers/vortex_controller.rb`):
|
|
208
|
+
|
|
197
209
|
```ruby
|
|
198
210
|
class VortexController < ApplicationController
|
|
199
211
|
before_action :require_authentication!
|
|
@@ -217,6 +229,7 @@ end
|
|
|
217
229
|
```
|
|
218
230
|
|
|
219
231
|
### Sinatra:
|
|
232
|
+
|
|
220
233
|
```ruby
|
|
221
234
|
require 'sinatra/base'
|
|
222
235
|
require 'json'
|
|
@@ -256,6 +269,7 @@ end
|
|
|
256
269
|
## Step 6: Implement Accept Invitations Endpoint (CRITICAL)
|
|
257
270
|
|
|
258
271
|
### Rails Routes (`config/routes.rb`):
|
|
272
|
+
|
|
259
273
|
```ruby
|
|
260
274
|
Rails.application.routes.draw do
|
|
261
275
|
namespace :api do
|
|
@@ -272,6 +286,7 @@ end
|
|
|
272
286
|
```
|
|
273
287
|
|
|
274
288
|
### Rails with ActiveRecord:
|
|
289
|
+
|
|
275
290
|
```ruby
|
|
276
291
|
class Api::VortexController < ApplicationController
|
|
277
292
|
before_action :require_authentication!
|
|
@@ -292,11 +307,11 @@ class Api::VortexController < ApplicationController
|
|
|
292
307
|
invitation_ids.each do |invitation_id|
|
|
293
308
|
invitation = vortex_client.get_invitation(invitation_id)
|
|
294
309
|
|
|
295
|
-
(invitation[
|
|
296
|
-
|
|
310
|
+
(invitation["groups"] || []).each do |scope|
|
|
311
|
+
ScopeMembership.find_or_create_by!(
|
|
297
312
|
user_id: current_user.id,
|
|
298
|
-
|
|
299
|
-
|
|
313
|
+
scope_type: scope['type'],
|
|
314
|
+
scope_id: scope['groupId']
|
|
300
315
|
) do |membership|
|
|
301
316
|
membership.role = invitation['role'] || 'member'
|
|
302
317
|
end
|
|
@@ -320,6 +335,7 @@ end
|
|
|
320
335
|
```
|
|
321
336
|
|
|
322
337
|
### Sinatra with Sequel:
|
|
338
|
+
|
|
323
339
|
```ruby
|
|
324
340
|
post '/api/vortex/invitations/accept' do
|
|
325
341
|
require_authentication!
|
|
@@ -340,14 +356,14 @@ post '/api/vortex/invitations/accept' do
|
|
|
340
356
|
invitation_ids.each do |invitation_id|
|
|
341
357
|
invitation = vortex.get_invitation(invitation_id)
|
|
342
358
|
|
|
343
|
-
(invitation[
|
|
344
|
-
|
|
345
|
-
target: [:user_id, :
|
|
359
|
+
(invitation["groups"] || []).each do |scope|
|
|
360
|
+
ScopeMembership.insert_conflict(
|
|
361
|
+
target: [:user_id, :scope_type, :scope_id],
|
|
346
362
|
update: { role: invitation['role'] || 'member' }
|
|
347
363
|
).insert(
|
|
348
364
|
user_id: current_user.id,
|
|
349
|
-
|
|
350
|
-
|
|
365
|
+
scope_type: scope['type'],
|
|
366
|
+
scope_id: scope['groupId'],
|
|
351
367
|
role: invitation['role'] || 'member',
|
|
352
368
|
joined_at: Time.now
|
|
353
369
|
)
|
|
@@ -368,6 +384,7 @@ end
|
|
|
368
384
|
```
|
|
369
385
|
|
|
370
386
|
**Critical - Adapt database logic:**
|
|
387
|
+
|
|
371
388
|
- Use their actual table/model names (from discovery)
|
|
372
389
|
- Use their actual field names
|
|
373
390
|
- Use their ORM pattern (ActiveRecord, Sequel)
|
|
@@ -378,19 +395,20 @@ end
|
|
|
378
395
|
## Step 7: Database Models
|
|
379
396
|
|
|
380
397
|
### Rails Migration:
|
|
398
|
+
|
|
381
399
|
```ruby
|
|
382
|
-
# db/migrate/
|
|
383
|
-
class
|
|
400
|
+
# db/migrate/YYYYMMDDHHMMSS_create_scope_memberships.rb
|
|
401
|
+
class CreateScopeMemberships < ActiveRecord::Migration[7.0]
|
|
384
402
|
def change
|
|
385
|
-
create_table :
|
|
403
|
+
create_table :scope_memberships do |t|
|
|
386
404
|
t.string :user_id, null: false
|
|
387
|
-
t.string :
|
|
388
|
-
t.string :
|
|
405
|
+
t.string :scope_type, null: false, limit: 100
|
|
406
|
+
t.string :scope_id, null: false
|
|
389
407
|
t.string :role, default: 'member', limit: 50
|
|
390
408
|
t.timestamp :joined_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
|
|
391
409
|
|
|
392
|
-
t.index [:user_id, :
|
|
393
|
-
t.index [:
|
|
410
|
+
t.index [:user_id, :scope_type, :scope_id], unique: true, name: 'unique_membership'
|
|
411
|
+
t.index [:scope_type, :scope_id], name: 'idx_scope'
|
|
394
412
|
t.index [:user_id], name: 'idx_user'
|
|
395
413
|
end
|
|
396
414
|
end
|
|
@@ -398,33 +416,35 @@ end
|
|
|
398
416
|
```
|
|
399
417
|
|
|
400
418
|
### Rails Model:
|
|
419
|
+
|
|
401
420
|
```ruby
|
|
402
|
-
# app/models/
|
|
403
|
-
class
|
|
421
|
+
# app/models/scope_membership.rb
|
|
422
|
+
class ScopeMembership < ApplicationRecord
|
|
404
423
|
validates :user_id, presence: true
|
|
405
|
-
validates :
|
|
406
|
-
validates :
|
|
424
|
+
validates :scope_type, presence: true
|
|
425
|
+
validates :scope_id, presence: true
|
|
407
426
|
validates :role, presence: true
|
|
408
427
|
|
|
409
|
-
validates :user_id, uniqueness: { scope: [:
|
|
428
|
+
validates :user_id, uniqueness: { scope: [:scope_type, :scope_id] }
|
|
410
429
|
end
|
|
411
430
|
```
|
|
412
431
|
|
|
413
432
|
### Sequel Migration:
|
|
433
|
+
|
|
414
434
|
```ruby
|
|
415
|
-
# db/migrations/
|
|
435
|
+
# db/migrations/001_create_scope_memberships.rb
|
|
416
436
|
Sequel.migration do
|
|
417
437
|
change do
|
|
418
|
-
create_table(:
|
|
438
|
+
create_table(:scope_memberships) do
|
|
419
439
|
primary_key :id
|
|
420
440
|
String :user_id, null: false
|
|
421
|
-
String :
|
|
422
|
-
String :
|
|
441
|
+
String :scope_type, size: 100, null: false
|
|
442
|
+
String :scope_id, null: false
|
|
423
443
|
String :role, size: 50, default: 'member'
|
|
424
444
|
DateTime :joined_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
425
445
|
|
|
426
|
-
index [:user_id, :
|
|
427
|
-
index [:
|
|
446
|
+
index [:user_id, :scope_type, :scope_id], unique: true, name: :unique_membership
|
|
447
|
+
index [:scope_type, :scope_id], name: :idx_scope
|
|
428
448
|
index [:user_id], name: :idx_user
|
|
429
449
|
end
|
|
430
450
|
end
|
|
@@ -450,6 +470,7 @@ curl -X POST http://localhost:3000/api/vortex/jwt \
|
|
|
450
470
|
```
|
|
451
471
|
|
|
452
472
|
Expected response:
|
|
473
|
+
|
|
453
474
|
```json
|
|
454
475
|
{
|
|
455
476
|
"jwt": "eyJhbGciOiJIUzI1NiIs..."
|
|
@@ -471,6 +492,7 @@ Expected response:
|
|
|
471
492
|
**CORS errors** → Add CORS middleware:
|
|
472
493
|
|
|
473
494
|
**Rails:**
|
|
495
|
+
|
|
474
496
|
```ruby
|
|
475
497
|
# Gemfile
|
|
476
498
|
gem 'rack-cors'
|
|
@@ -485,6 +507,7 @@ end
|
|
|
485
507
|
```
|
|
486
508
|
|
|
487
509
|
**Sinatra:**
|
|
510
|
+
|
|
488
511
|
```ruby
|
|
489
512
|
require 'sinatra/cross_origin'
|
|
490
513
|
|
|
@@ -508,13 +531,15 @@ end
|
|
|
508
531
|
## After Implementation Report
|
|
509
532
|
|
|
510
533
|
List files created/modified:
|
|
534
|
+
|
|
511
535
|
- Dependency: Gemfile
|
|
512
536
|
- Client: config/initializers/vortex.rb (or concern)
|
|
513
537
|
- Controller: app/controllers/vortex_controller.rb (or Sinatra routes)
|
|
514
|
-
- Model: app/models/
|
|
515
|
-
- Migration: db/migrate/
|
|
538
|
+
- Model: app/models/scope_membership.rb
|
|
539
|
+
- Migration: db/migrate/XXX_create_scope_memberships.rb
|
|
516
540
|
|
|
517
541
|
Confirm:
|
|
542
|
+
|
|
518
543
|
- Vortex gem installed
|
|
519
544
|
- VortexClient instance created
|
|
520
545
|
- JWT endpoint returns valid JWT
|
|
@@ -525,6 +550,7 @@ Confirm:
|
|
|
525
550
|
## Endpoints Registered
|
|
526
551
|
|
|
527
552
|
All endpoints at `/api/vortex`:
|
|
553
|
+
|
|
528
554
|
- `POST /jwt` - Generate JWT for authenticated user
|
|
529
555
|
- `GET /invitations` - Get invitations by target
|
|
530
556
|
- `GET /invitations/:id` - Get invitation by ID
|
data/README.md
CHANGED
|
@@ -61,8 +61,37 @@ invitations = client.get_invitations_by_target('email', 'user@example.com')
|
|
|
61
61
|
# Accept an invitation
|
|
62
62
|
client.accept_invitation('inv-123', { email: 'user@example.com' })
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
|
|
64
|
+
# Accept with new vs existing user tracking
|
|
65
|
+
# is_existing: true if user was already registered, false if new signup
|
|
66
|
+
client.accept_invitation('inv-123', {
|
|
67
|
+
email: 'user@example.com',
|
|
68
|
+
is_existing: false # New user signup
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# Get invitations by scope
|
|
72
|
+
scope_invitations = client.get_invitations_by_scope('team', 'team1')
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Token Generation (for Widgets)
|
|
76
|
+
|
|
77
|
+
Use `generate_token` for widget authentication. This method generates a signed JWT token that can be passed to Vortex widgets via the `token` prop.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Sign just the user (minimum for secure invitation attribution)
|
|
81
|
+
token = client.generate_token({ user: { id: 'user-123' } })
|
|
82
|
+
|
|
83
|
+
# Sign full payload with component, scope, and variables
|
|
84
|
+
token = client.generate_token({
|
|
85
|
+
component: 'widget-abc',
|
|
86
|
+
user: { id: 'user-123', name: 'Peter', email: 'peter@example.com' },
|
|
87
|
+
scope: 'workspace_456',
|
|
88
|
+
vars: { company_name: 'Acme' }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
# Custom expiration (default is 5 minutes)
|
|
92
|
+
payload = { user: { id: 'user-123' } }
|
|
93
|
+
token = client.generate_token(payload, { expires_in: '1h' }) # Supports "5m", "1h", "24h", "7d"
|
|
94
|
+
token = client.generate_token(payload, { expires_in: 3600 }) # Or seconds as integer
|
|
66
95
|
```
|
|
67
96
|
|
|
68
97
|
## Rails Integration
|
|
@@ -116,8 +145,8 @@ Rails.application.routes.draw do
|
|
|
116
145
|
get 'invitations/:invitation_id', action: 'get_invitation'
|
|
117
146
|
delete 'invitations/:invitation_id', action: 'revoke_invitation'
|
|
118
147
|
post 'invitations/accept', action: 'accept_invitations'
|
|
119
|
-
get 'invitations/by-
|
|
120
|
-
delete 'invitations/by-
|
|
148
|
+
get 'invitations/by-scope/:scope_type/:scope', action: 'get_invitations_by_scope'
|
|
149
|
+
delete 'invitations/by-scope/:scope_type/:scope', action: 'delete_invitations_by_scope'
|
|
121
150
|
post 'invitations/:invitation_id/reinvite', action: 'reinvite'
|
|
122
151
|
end
|
|
123
152
|
end
|
|
@@ -171,8 +200,8 @@ All methods match the functionality of other Vortex SDKs:
|
|
|
171
200
|
- `get_invitation(invitation_id)` - Get specific invitation
|
|
172
201
|
- `revoke_invitation(invitation_id)` - Revoke invitation
|
|
173
202
|
- `accept_invitation(invitation_id, user)` - Accept an invitation
|
|
174
|
-
- `
|
|
175
|
-
- `
|
|
203
|
+
- `get_invitations_by_scope(scope_type, scope)` - Get scope invitations
|
|
204
|
+
- `delete_invitations_by_scope(scope_type, scope)` - Delete scope invitations
|
|
176
205
|
- `reinvite(invitation_id)` - Reinvite user
|
|
177
206
|
- `sync_internal_invitation(creator_id, target_value, action, component_id)` - Sync internal invitation action
|
|
178
207
|
|
|
@@ -182,12 +211,12 @@ The SDK provides these routes (same as other SDKs for React provider compatibili
|
|
|
182
211
|
|
|
183
212
|
- `POST /api/vortex/jwt`
|
|
184
213
|
- `GET /api/vortex/invitations?targetType=email&targetValue=user@example.com`
|
|
185
|
-
- `GET /api/vortex/invitations/:
|
|
186
|
-
- `DELETE /api/vortex/invitations/:
|
|
214
|
+
- `GET /api/vortex/invitations/:invitation_id`
|
|
215
|
+
- `DELETE /api/vortex/invitations/:invitation_id`
|
|
187
216
|
- `POST /api/vortex/invitations/accept`
|
|
188
|
-
- `GET /api/vortex/invitations/by-
|
|
189
|
-
- `DELETE /api/vortex/invitations/by-
|
|
190
|
-
- `POST /api/vortex/invitations/:
|
|
217
|
+
- `GET /api/vortex/invitations/by-scope/:scope_type/:scope`
|
|
218
|
+
- `DELETE /api/vortex/invitations/by-scope/:scope_type/:scope`
|
|
219
|
+
- `POST /api/vortex/invitations/:invitation_id/reinvite`
|
|
191
220
|
- `POST /api/vortex/invitations/sync-internal-invitation`
|
|
192
221
|
|
|
193
222
|
## Sync Internal Invitation
|
|
@@ -208,16 +237,19 @@ puts "Invitation IDs: #{result['invitationIds']}"
|
|
|
208
237
|
```
|
|
209
238
|
|
|
210
239
|
**Parameters:**
|
|
240
|
+
|
|
211
241
|
- `creator_id` (String) — The inviter's user ID in your system
|
|
212
242
|
- `target_value` (String) — The invitee's user ID in your system
|
|
213
243
|
- `action` ("accepted" | "declined") — The invitation decision
|
|
214
244
|
- `component_id` (String) — The widget component UUID
|
|
215
245
|
|
|
216
246
|
**Response:**
|
|
247
|
+
|
|
217
248
|
- `processed` (Integer) — Count of invitations processed
|
|
218
249
|
- `invitationIds` (Array<String>) — IDs of processed invitations
|
|
219
250
|
|
|
220
251
|
**Use cases:**
|
|
252
|
+
|
|
221
253
|
- You handle invitation delivery through your own in-app notifications or UI
|
|
222
254
|
- Users accept/decline invitations within your application
|
|
223
255
|
- You need to keep Vortex updated with the invitation status
|
data/examples/basic_usage.rb
CHANGED
|
@@ -50,7 +50,7 @@ begin
|
|
|
50
50
|
|
|
51
51
|
# 3. Get invitations by group
|
|
52
52
|
puts "3. Getting invitations for team group..."
|
|
53
|
-
group_invitations = client.
|
|
53
|
+
group_invitations = client.get_invitations_by_scope('team', 'team1')
|
|
54
54
|
puts "Found #{group_invitations.length} group invitation(s)"
|
|
55
55
|
puts
|
|
56
56
|
|
data/examples/rails_app.rb
CHANGED
|
@@ -79,8 +79,8 @@ Rails.application.routes.draw do
|
|
|
79
79
|
get 'invitations/:invitation_id', action: 'get_invitation'
|
|
80
80
|
delete 'invitations/:invitation_id', action: 'revoke_invitation'
|
|
81
81
|
post 'invitations/accept', action: 'accept_invitations'
|
|
82
|
-
get 'invitations/by-
|
|
83
|
-
delete 'invitations/by-
|
|
82
|
+
get 'invitations/by-scope/:scope_type/:scope', action: 'get_invitations_by_scope'
|
|
83
|
+
delete 'invitations/by-scope/:scope_type/:scope', action: 'delete_invitations_by_scope'
|
|
84
84
|
post 'invitations/:invitation_id/reinvite', action: 'reinvite'
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -99,8 +99,8 @@ Rails.application.routes.draw do
|
|
|
99
99
|
invitation: 'GET /api/vortex/invitations/:id',
|
|
100
100
|
revoke: 'DELETE /api/vortex/invitations/:id',
|
|
101
101
|
accept: 'POST /api/vortex/invitations/accept',
|
|
102
|
-
group_invitations: 'GET /api/vortex/invitations/by-
|
|
103
|
-
delete_group: 'DELETE /api/vortex/invitations/by-
|
|
102
|
+
group_invitations: 'GET /api/vortex/invitations/by-scope/:type/:id',
|
|
103
|
+
delete_group: 'DELETE /api/vortex/invitations/by-scope/:type/:id',
|
|
104
104
|
reinvite: 'POST /api/vortex/invitations/:id/reinvite'
|
|
105
105
|
}
|
|
106
106
|
}.to_json
|
|
@@ -119,8 +119,8 @@ if __FILE__ == $0
|
|
|
119
119
|
puts " GET /api/vortex/invitations/:id"
|
|
120
120
|
puts " DELETE /api/vortex/invitations/:id"
|
|
121
121
|
puts " POST /api/vortex/invitations/accept"
|
|
122
|
-
puts " GET /api/vortex/invitations/by-
|
|
123
|
-
puts " DELETE /api/vortex/invitations/by-
|
|
122
|
+
puts " GET /api/vortex/invitations/by-scope/:type/:id"
|
|
123
|
+
puts " DELETE /api/vortex/invitations/by-scope/:type/:id"
|
|
124
124
|
puts " POST /api/vortex/invitations/:id/reinvite"
|
|
125
125
|
|
|
126
126
|
Rails.application.initialize!
|
data/examples/sinatra_app.rb
CHANGED
|
@@ -73,8 +73,8 @@ class VortexSinatraApp < Sinatra::Base
|
|
|
73
73
|
invitation: 'GET /api/vortex/invitations/:id',
|
|
74
74
|
revoke: 'DELETE /api/vortex/invitations/:id',
|
|
75
75
|
accept: 'POST /api/vortex/invitations/accept',
|
|
76
|
-
group_invitations: 'GET /api/vortex/invitations/by-
|
|
77
|
-
delete_group: 'DELETE /api/vortex/invitations/by-
|
|
76
|
+
group_invitations: 'GET /api/vortex/invitations/by-scope/:type/:id',
|
|
77
|
+
delete_group: 'DELETE /api/vortex/invitations/by-scope/:type/:id',
|
|
78
78
|
reinvite: 'POST /api/vortex/invitations/:id/reinvite'
|
|
79
79
|
}
|
|
80
80
|
}.to_json
|
|
@@ -105,8 +105,8 @@ if __FILE__ == $0
|
|
|
105
105
|
puts " GET /api/vortex/invitations/:id"
|
|
106
106
|
puts " DELETE /api/vortex/invitations/:id"
|
|
107
107
|
puts " POST /api/vortex/invitations/accept"
|
|
108
|
-
puts " GET /api/vortex/invitations/by-
|
|
109
|
-
puts " DELETE /api/vortex/invitations/by-
|
|
108
|
+
puts " GET /api/vortex/invitations/by-scope/:type/:id"
|
|
109
|
+
puts " DELETE /api/vortex/invitations/by-scope/:type/:id"
|
|
110
110
|
puts " POST /api/vortex/invitations/:id/reinvite"
|
|
111
111
|
puts
|
|
112
112
|
puts "Authentication headers (for testing):"
|
data/lib/vortex/client.rb
CHANGED
|
@@ -44,6 +44,84 @@ module Vortex
|
|
|
44
44
|
# user: { id: 'user-123', email: 'user@example.com' },
|
|
45
45
|
# attributes: { role: 'admin', department: 'Engineering' }
|
|
46
46
|
# })
|
|
47
|
+
# Sign a user object for use with the widget signature prop.
|
|
48
|
+
#
|
|
49
|
+
# @param user [Hash] User data with :id, :email, etc.
|
|
50
|
+
# @return [String] Signature in "kid:hexDigest" format
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# client = Vortex::Client.new(ENV['VORTEX_API_KEY'])
|
|
54
|
+
# signature = client.sign({ id: 'user-123', email: 'user@example.com' })
|
|
55
|
+
# # Pass signature to frontend alongside user prop
|
|
56
|
+
def sign(user)
|
|
57
|
+
user_id = user[:id] || user['id']
|
|
58
|
+
raise VortexError, 'User must have an :id field' if user_id.nil? || user_id.to_s.empty?
|
|
59
|
+
|
|
60
|
+
kid, signing_key = parse_and_derive_key
|
|
61
|
+
|
|
62
|
+
# Build canonical payload — include ALL user fields with key normalization
|
|
63
|
+
key_map = { id: 'userId', email: 'userEmail',
|
|
64
|
+
admin_scopes: 'adminScopes', allowed_email_domains: 'allowedEmailDomains' }
|
|
65
|
+
canonical = {}
|
|
66
|
+
user.each do |k, v|
|
|
67
|
+
str_key = k.to_s
|
|
68
|
+
mapped = key_map[k] || key_map[k.to_s.to_sym]
|
|
69
|
+
if mapped
|
|
70
|
+
canonical[mapped] = v
|
|
71
|
+
elsif str_key == 'id'
|
|
72
|
+
canonical['userId'] = v
|
|
73
|
+
elsif str_key == 'email'
|
|
74
|
+
canonical['userEmail'] = v
|
|
75
|
+
elsif !%w[name user_name avatar_url user_avatar_url].include?(str_key)
|
|
76
|
+
# Skip name/avatar fields here - handle them explicitly below
|
|
77
|
+
canonical[str_key] = v
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
canonical['userId'] = user_id # ensure normalized
|
|
81
|
+
|
|
82
|
+
# Prefer new property names (name/avatar_url), fall back to deprecated (user_name/user_avatar_url)
|
|
83
|
+
user_name = user[:name] || user[:user_name]
|
|
84
|
+
canonical['name'] = user_name if user_name
|
|
85
|
+
|
|
86
|
+
user_avatar_url = user[:avatar_url] || user[:user_avatar_url]
|
|
87
|
+
canonical['avatarUrl'] = user_avatar_url if user_avatar_url
|
|
88
|
+
|
|
89
|
+
# Recursive canonical JSON (sorted keys at every level)
|
|
90
|
+
canonical_json = JSON.generate(canonicalize_value(canonical))
|
|
91
|
+
|
|
92
|
+
digest = OpenSSL::HMAC.hexdigest('SHA256', signing_key, canonical_json)
|
|
93
|
+
"#{kid}:#{digest}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def canonicalize_value(value)
|
|
99
|
+
case value
|
|
100
|
+
when Hash
|
|
101
|
+
value.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, canonicalize_value(v)] }.to_h
|
|
102
|
+
when Array
|
|
103
|
+
value.map { |item| canonicalize_value(item) }
|
|
104
|
+
else
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_and_derive_key
|
|
110
|
+
parts = @api_key.split('.')
|
|
111
|
+
raise VortexError, 'Invalid API key format' unless parts.length == 3
|
|
112
|
+
prefix, encoded_id, key = parts
|
|
113
|
+
raise VortexError, 'Invalid API key format' unless prefix && encoded_id && key
|
|
114
|
+
raise VortexError, 'Invalid API key prefix' unless prefix == 'VRTX'
|
|
115
|
+
|
|
116
|
+
uuid_bytes = Base64.urlsafe_decode64(encoded_id)
|
|
117
|
+
raise VortexError, "Invalid API key: decoded UUID must be 16 bytes, got #{uuid_bytes.length}" unless uuid_bytes.length == 16
|
|
118
|
+
kid = format_uuid(uuid_bytes)
|
|
119
|
+
signing_key = OpenSSL::HMAC.digest('SHA256', key, kid)
|
|
120
|
+
[kid, signing_key]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
public
|
|
124
|
+
|
|
47
125
|
def generate_jwt(params)
|
|
48
126
|
user = params[:user]
|
|
49
127
|
attributes = params[:attributes]
|
|
@@ -80,14 +158,16 @@ module Vortex
|
|
|
80
158
|
expires: expires
|
|
81
159
|
}
|
|
82
160
|
|
|
83
|
-
# Add name if present (
|
|
84
|
-
|
|
85
|
-
|
|
161
|
+
# Add name if present (prefer new property, fall back to deprecated)
|
|
162
|
+
user_name = user[:name] || user[:user_name]
|
|
163
|
+
if user_name
|
|
164
|
+
payload[:name] = user_name
|
|
86
165
|
end
|
|
87
166
|
|
|
88
|
-
# Add
|
|
89
|
-
|
|
90
|
-
|
|
167
|
+
# Add avatarUrl if present (prefer new property, fall back to deprecated)
|
|
168
|
+
user_avatar_url = user[:avatar_url] || user[:user_avatar_url]
|
|
169
|
+
if user_avatar_url
|
|
170
|
+
payload[:avatarUrl] = user_avatar_url
|
|
91
171
|
end
|
|
92
172
|
|
|
93
173
|
# Add adminScopes if present
|
|
@@ -118,6 +198,153 @@ module Vortex
|
|
|
118
198
|
raise VortexError, "JWT generation failed: #{e.message}"
|
|
119
199
|
end
|
|
120
200
|
|
|
201
|
+
# Generate a signed token for use with Vortex widgets.
|
|
202
|
+
#
|
|
203
|
+
# This method generates a signed JWT token containing your payload data.
|
|
204
|
+
# The token can be passed to widgets via the `token` prop to authenticate
|
|
205
|
+
# and authorize the request.
|
|
206
|
+
#
|
|
207
|
+
# @param payload [Hash] Data to sign (user, component, scope, vars, etc.)
|
|
208
|
+
# At minimum, include user[:id] for secure invitation attribution.
|
|
209
|
+
# @param options [Hash] Optional configuration
|
|
210
|
+
# @option options [String, Integer] :expires_in Expiration time (default: 5 minutes)
|
|
211
|
+
# Can be a duration string ("5m", "1h", "24h", "7d") or seconds as integer.
|
|
212
|
+
# @return [String] Signed JWT token
|
|
213
|
+
# @raise [VortexError] If API key format is invalid or token generation fails
|
|
214
|
+
#
|
|
215
|
+
# @example Sign just the user (minimum for secure attribution)
|
|
216
|
+
# token = client.generate_token({ user: { id: 'user-123' } })
|
|
217
|
+
#
|
|
218
|
+
# @example Sign full payload
|
|
219
|
+
# token = client.generate_token({
|
|
220
|
+
# component: 'widget-abc',
|
|
221
|
+
# user: { id: 'user-123', name: 'Peter', email: 'peter@example.com' },
|
|
222
|
+
# scope: 'workspace_456',
|
|
223
|
+
# vars: { company_name: 'Acme' }
|
|
224
|
+
# })
|
|
225
|
+
#
|
|
226
|
+
# @example Custom expiration (default is 5 minutes)
|
|
227
|
+
# token = client.generate_token(payload, { expires_in: '1h' })
|
|
228
|
+
# token = client.generate_token(payload, { expires_in: 3600 }) # seconds
|
|
229
|
+
def generate_token(payload, options = nil)
|
|
230
|
+
# Validate inputs
|
|
231
|
+
raise VortexError, "payload must be a Hash" unless payload.is_a?(Hash)
|
|
232
|
+
raise VortexError, "options must be a Hash or nil" if options && !options.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
# Normalize payload keys to symbols
|
|
235
|
+
payload = symbolize_keys(payload)
|
|
236
|
+
|
|
237
|
+
# Warn if user.id is missing
|
|
238
|
+
user = payload[:user]
|
|
239
|
+
if user.nil? || user[:id].nil? || user[:id].to_s.empty?
|
|
240
|
+
warn "[Vortex SDK] Warning: signing payload without user.id means invitations won't be securely attributed to a user."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Parse expiration
|
|
244
|
+
expires_in_seconds = 5 * 60 # Default: 5 minutes
|
|
245
|
+
if options
|
|
246
|
+
options = symbolize_keys(options)
|
|
247
|
+
raw_expires = options[:expires_in] || options[:expiresIn]
|
|
248
|
+
expires_in_seconds = parse_expires_in(raw_expires) if raw_expires
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Parse API key and derive signing key
|
|
252
|
+
kid, signing_key = parse_and_derive_key
|
|
253
|
+
|
|
254
|
+
# Build JWT
|
|
255
|
+
now = Time.now.to_i
|
|
256
|
+
exp = now + expires_in_seconds
|
|
257
|
+
|
|
258
|
+
header = { alg: 'HS256', typ: 'JWT', kid: kid }
|
|
259
|
+
|
|
260
|
+
# Build JWT payload
|
|
261
|
+
jwt_payload = {}
|
|
262
|
+
|
|
263
|
+
# Add user if present
|
|
264
|
+
if user
|
|
265
|
+
user_map = {}
|
|
266
|
+
user_map[:id] = user[:id] if user[:id]
|
|
267
|
+
user_map[:email] = user[:email] if user[:email]
|
|
268
|
+
user_map[:name] = user[:name] if user[:name]
|
|
269
|
+
user_map[:avatarUrl] = user[:avatar_url] || user[:avatarUrl] if user[:avatar_url] || user[:avatarUrl]
|
|
270
|
+
user_map[:adminScopes] = user[:admin_scopes] || user[:adminScopes] if user[:admin_scopes] || user[:adminScopes]
|
|
271
|
+
user_map[:allowedEmailDomains] = user[:allowed_email_domains] || user[:allowedEmailDomains] if user[:allowed_email_domains] || user[:allowedEmailDomains]
|
|
272
|
+
jwt_payload[:user] = user_map unless user_map.empty?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Add other payload fields
|
|
276
|
+
jwt_payload[:component] = payload[:component] if payload[:component]
|
|
277
|
+
jwt_payload[:scope] = payload[:scope] if payload[:scope]
|
|
278
|
+
jwt_payload[:vars] = payload[:vars] if payload[:vars] && !payload[:vars].empty?
|
|
279
|
+
|
|
280
|
+
# Add any extra fields from payload (except known keys)
|
|
281
|
+
known_keys = %i[user component scope vars]
|
|
282
|
+
payload.each do |k, v|
|
|
283
|
+
jwt_payload[k] = v unless known_keys.include?(k)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Add JWT claims
|
|
287
|
+
jwt_payload[:iat] = now
|
|
288
|
+
jwt_payload[:exp] = exp
|
|
289
|
+
|
|
290
|
+
# Encode header and payload
|
|
291
|
+
header_b64 = base64url_encode(JSON.generate(header))
|
|
292
|
+
payload_b64 = base64url_encode(JSON.generate(jwt_payload))
|
|
293
|
+
|
|
294
|
+
# Sign
|
|
295
|
+
to_sign = "#{header_b64}.#{payload_b64}"
|
|
296
|
+
signature = OpenSSL::HMAC.digest('SHA256', signing_key, to_sign)
|
|
297
|
+
signature_b64 = base64url_encode(signature)
|
|
298
|
+
|
|
299
|
+
"#{to_sign}.#{signature_b64}"
|
|
300
|
+
rescue => e
|
|
301
|
+
raise VortexError, "Token generation failed: #{e.message}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
# Parse expiration time string or int into seconds.
|
|
307
|
+
# Supports: '5m', '1h', '24h', '7d' or raw seconds as int.
|
|
308
|
+
#
|
|
309
|
+
# @param expires_in [String, Integer] Expiration time
|
|
310
|
+
# @return [Integer] Expiration time in seconds
|
|
311
|
+
# @raise [VortexError] If format is invalid
|
|
312
|
+
def parse_expires_in(expires_in)
|
|
313
|
+
return 5 * 60 if expires_in.nil?
|
|
314
|
+
|
|
315
|
+
if expires_in.is_a?(Integer)
|
|
316
|
+
raise VortexError, "Invalid expires_in value: #{expires_in}. Numeric expires_in must be a positive integer number of seconds." if expires_in <= 0
|
|
317
|
+
return expires_in
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
unless expires_in.is_a?(String)
|
|
321
|
+
raise VortexError, "Invalid expires_in type: #{expires_in.class}. Must be String or Integer."
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
match = expires_in.match(/^(\d+)(m|h|d)$/)
|
|
325
|
+
unless match
|
|
326
|
+
raise VortexError, "Invalid expires_in format: \"#{expires_in}\". Use format like \"5m\", \"1h\", \"24h\", \"7d\" or a number of seconds."
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
value = match[1].to_i
|
|
330
|
+
if value <= 0
|
|
331
|
+
raise VortexError, "Invalid expires_in value: \"#{expires_in}\". Duration must be positive (e.g., \"5m\", \"1h\", \"7d\")."
|
|
332
|
+
end
|
|
333
|
+
unit = match[2]
|
|
334
|
+
|
|
335
|
+
multipliers = { 'm' => 60, 'h' => 60 * 60, 'd' => 60 * 60 * 24 }
|
|
336
|
+
value * multipliers[unit]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Recursively convert hash keys to symbols
|
|
340
|
+
def symbolize_keys(hash)
|
|
341
|
+
return hash unless hash.is_a?(Hash)
|
|
342
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
343
|
+
key = k.is_a?(String) ? k.to_sym : k
|
|
344
|
+
result[key] = v.is_a?(Hash) ? symbolize_keys(v) : v
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
121
348
|
public
|
|
122
349
|
|
|
123
350
|
# Get invitations by target
|
|
@@ -132,7 +359,7 @@ module Vortex
|
|
|
132
359
|
req.params['targetValue'] = target_value
|
|
133
360
|
end
|
|
134
361
|
|
|
135
|
-
handle_response(response)['invitations'] || []
|
|
362
|
+
transform_invitation_results(handle_response(response)['invitations'] || [])
|
|
136
363
|
rescue => e
|
|
137
364
|
raise VortexError, "Failed to get invitations by target: #{e.message}"
|
|
138
365
|
end
|
|
@@ -144,7 +371,7 @@ module Vortex
|
|
|
144
371
|
# @raise [VortexError] If the request fails
|
|
145
372
|
def get_invitation(invitation_id)
|
|
146
373
|
response = @connection.get("/api/v1/invitations/#{invitation_id}")
|
|
147
|
-
handle_response(response)
|
|
374
|
+
transform_invitation_result(handle_response(response))
|
|
148
375
|
rescue => e
|
|
149
376
|
raise VortexError, "Failed to get invitation: #{e.message}"
|
|
150
377
|
end
|
|
@@ -236,9 +463,17 @@ module Vortex
|
|
|
236
463
|
# Validate that either email or phone is provided
|
|
237
464
|
raise VortexError, 'User must have either email or phone' if user[:email].nil? && user[:phone].nil?
|
|
238
465
|
|
|
466
|
+
# Transform user keys to camelCase for API
|
|
467
|
+
api_user = user.compact.transform_keys do |key|
|
|
468
|
+
case key
|
|
469
|
+
when :is_existing then :isExisting
|
|
470
|
+
else key
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
239
474
|
body = {
|
|
240
475
|
invitationIds: invitation_ids,
|
|
241
|
-
user:
|
|
476
|
+
user: api_user
|
|
242
477
|
}
|
|
243
478
|
|
|
244
479
|
response = @connection.post('/api/v1/invitations/accept') do |req|
|
|
@@ -246,7 +481,7 @@ module Vortex
|
|
|
246
481
|
req.body = JSON.generate(body)
|
|
247
482
|
end
|
|
248
483
|
|
|
249
|
-
handle_response(response)
|
|
484
|
+
transform_invitation_result(handle_response(response))
|
|
250
485
|
rescue VortexError
|
|
251
486
|
raise
|
|
252
487
|
rescue => e
|
|
@@ -271,26 +506,26 @@ module Vortex
|
|
|
271
506
|
|
|
272
507
|
# Get invitations by group
|
|
273
508
|
#
|
|
274
|
-
# @param
|
|
275
|
-
# @param
|
|
509
|
+
# @param scope_type [String] The group type
|
|
510
|
+
# @param scope [String] The group ID
|
|
276
511
|
# @return [Array<Hash>] List of invitations for the group
|
|
277
512
|
# @raise [VortexError] If the request fails
|
|
278
|
-
def
|
|
279
|
-
response = @connection.get("/api/v1/invitations/by-
|
|
513
|
+
def get_invitations_by_scope(scope_type, scope)
|
|
514
|
+
response = @connection.get("/api/v1/invitations/by-scope/#{scope_type}/#{scope}")
|
|
280
515
|
result = handle_response(response)
|
|
281
|
-
result['invitations'] || []
|
|
516
|
+
transform_invitation_results(result['invitations'] || [])
|
|
282
517
|
rescue => e
|
|
283
518
|
raise VortexError, "Failed to get group invitations: #{e.message}"
|
|
284
519
|
end
|
|
285
520
|
|
|
286
521
|
# Delete invitations by group
|
|
287
522
|
#
|
|
288
|
-
# @param
|
|
289
|
-
# @param
|
|
523
|
+
# @param scope_type [String] The group type
|
|
524
|
+
# @param scope [String] The group ID
|
|
290
525
|
# @return [Hash] Success response
|
|
291
526
|
# @raise [VortexError] If the request fails
|
|
292
|
-
def
|
|
293
|
-
response = @connection.delete("/api/v1/invitations/by-
|
|
527
|
+
def delete_invitations_by_scope(scope_type, scope)
|
|
528
|
+
response = @connection.delete("/api/v1/invitations/by-scope/#{scope_type}/#{scope}")
|
|
294
529
|
handle_response(response)
|
|
295
530
|
rescue => e
|
|
296
531
|
raise VortexError, "Failed to delete group invitations: #{e.message}"
|
|
@@ -303,7 +538,7 @@ module Vortex
|
|
|
303
538
|
# @raise [VortexError] If the request fails
|
|
304
539
|
def reinvite(invitation_id)
|
|
305
540
|
response = @connection.post("/api/v1/invitations/#{invitation_id}/reinvite")
|
|
306
|
-
handle_response(response)
|
|
541
|
+
transform_invitation_result(handle_response(response))
|
|
307
542
|
rescue => e
|
|
308
543
|
raise VortexError, "Failed to reinvite: #{e.message}"
|
|
309
544
|
end
|
|
@@ -322,7 +557,7 @@ module Vortex
|
|
|
322
557
|
# @param widget_configuration_id [String] The widget configuration ID to use
|
|
323
558
|
# @param target [Hash] The invitation target: { type: 'email|sms|internal', value: '...' }
|
|
324
559
|
# @param inviter [Hash] The inviter info: { user_id: '...', user_email: '...', name: '...' }
|
|
325
|
-
# @param groups [Array<Hash>, nil] Optional groups: [{ type: '...',
|
|
560
|
+
# @param groups [Array<Hash>, nil] Optional groups: [{ type: '...', scope: '...', name: '...' }]
|
|
326
561
|
# @param source [String, nil] Optional source for analytics (defaults to 'api')
|
|
327
562
|
# @param subtype [String, nil] Optional subtype for analytics segmentation (e.g., 'pymk', 'find-friends')
|
|
328
563
|
# @param template_variables [Hash, nil] Optional template variables for email customization
|
|
@@ -336,7 +571,7 @@ module Vortex
|
|
|
336
571
|
# 'widget-config-123',
|
|
337
572
|
# { type: 'email', value: 'invitee@example.com' },
|
|
338
573
|
# { user_id: 'user-456', user_email: 'inviter@example.com', name: 'John Doe' },
|
|
339
|
-
# [{ type: 'team',
|
|
574
|
+
# [{ type: 'team', scope: 'team-789', name: 'Engineering' }],
|
|
340
575
|
# nil,
|
|
341
576
|
# nil,
|
|
342
577
|
# nil,
|
|
@@ -351,20 +586,28 @@ module Vortex
|
|
|
351
586
|
# nil,
|
|
352
587
|
# 'pymk'
|
|
353
588
|
# )
|
|
354
|
-
def create_invitation(widget_configuration_id, target, inviter, groups = nil, source = nil, subtype = nil, template_variables = nil, metadata = nil, unfurl_config = nil)
|
|
589
|
+
def create_invitation(widget_configuration_id, target, inviter, groups = nil, source = nil, subtype = nil, template_variables = nil, metadata = nil, unfurl_config = nil, scopes: nil, scope_id: nil, scope_type: nil, scope_name: nil)
|
|
355
590
|
raise VortexError, 'widget_configuration_id is required' if widget_configuration_id.nil? || widget_configuration_id.empty?
|
|
356
591
|
raise VortexError, 'target must have type and value' if target[:type].nil? || target[:value].nil?
|
|
357
592
|
raise VortexError, 'inviter must have user_id' if inviter[:user_id].nil?
|
|
358
593
|
|
|
594
|
+
# Scope translation: flat params > scopes > groups
|
|
595
|
+
if scope_id && groups.nil? && scopes.nil?
|
|
596
|
+
groups = [{ type: scope_type || '', scope_id: scope_id, name: scope_name || '' }]
|
|
597
|
+
elsif scopes && groups.nil?
|
|
598
|
+
groups = scopes
|
|
599
|
+
end
|
|
600
|
+
|
|
359
601
|
# Build request body with camelCase keys for the API
|
|
602
|
+
# Prefer new property names (name/avatar_url), fall back to deprecated (user_name/user_avatar_url)
|
|
360
603
|
body = {
|
|
361
604
|
widgetConfigurationId: widget_configuration_id,
|
|
362
605
|
target: target,
|
|
363
606
|
inviter: {
|
|
364
607
|
userId: inviter[:user_id],
|
|
365
608
|
userEmail: inviter[:user_email],
|
|
366
|
-
|
|
367
|
-
|
|
609
|
+
name: inviter[:name] || inviter[:user_name],
|
|
610
|
+
avatarUrl: inviter[:avatar_url] || inviter[:user_avatar_url]
|
|
368
611
|
}.compact
|
|
369
612
|
}
|
|
370
613
|
|
|
@@ -372,7 +615,7 @@ module Vortex
|
|
|
372
615
|
body[:groups] = groups.map do |g|
|
|
373
616
|
{
|
|
374
617
|
type: g[:type],
|
|
375
|
-
groupId: g[:group_id],
|
|
618
|
+
groupId: g[:scope] || g[:scope_id] || g[:group_id] || g[:scopeId] || g[:groupId],
|
|
376
619
|
name: g[:name]
|
|
377
620
|
}
|
|
378
621
|
end
|
|
@@ -421,7 +664,11 @@ module Vortex
|
|
|
421
664
|
encoded_scope = URI.encode_www_form_component(scope)
|
|
422
665
|
|
|
423
666
|
response = @connection.get("/api/v1/invitations/by-scope/#{encoded_scope_type}/#{encoded_scope}/autojoin")
|
|
424
|
-
handle_response(response)
|
|
667
|
+
result = handle_response(response)
|
|
668
|
+
if result.is_a?(Hash) && result['invitation'].is_a?(Hash)
|
|
669
|
+
result['invitation'] = transform_invitation_result(result['invitation'])
|
|
670
|
+
end
|
|
671
|
+
result
|
|
425
672
|
rescue VortexError
|
|
426
673
|
raise
|
|
427
674
|
rescue => e
|
|
@@ -472,7 +719,11 @@ module Vortex
|
|
|
472
719
|
req.body = JSON.generate(body)
|
|
473
720
|
end
|
|
474
721
|
|
|
475
|
-
handle_response(response)
|
|
722
|
+
result = handle_response(response)
|
|
723
|
+
if result.is_a?(Hash) && result['invitation'].is_a?(Hash)
|
|
724
|
+
result['invitation'] = transform_invitation_result(result['invitation'])
|
|
725
|
+
end
|
|
726
|
+
result
|
|
476
727
|
rescue VortexError
|
|
477
728
|
raise
|
|
478
729
|
rescue => e
|
|
@@ -521,6 +772,29 @@ module Vortex
|
|
|
521
772
|
|
|
522
773
|
private
|
|
523
774
|
|
|
775
|
+
# Transform an invitation scope by adding scope_id (= groupId)
|
|
776
|
+
def transform_scope(scope)
|
|
777
|
+
return scope unless scope.is_a?(Hash) && scope.key?('groupId')
|
|
778
|
+
scope.merge('scopeId' => scope['groupId'])
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Transform an invitation result by adding scopes (= groups with scopeId)
|
|
782
|
+
def transform_invitation_result(result)
|
|
783
|
+
return result unless result.is_a?(Hash)
|
|
784
|
+
if result['groups'].is_a?(Array)
|
|
785
|
+
mapped = result['groups'].map { |g| transform_scope(g) }
|
|
786
|
+
result['scopes'] = mapped
|
|
787
|
+
result['groups'] = mapped
|
|
788
|
+
end
|
|
789
|
+
result
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Transform an array of invitation results
|
|
793
|
+
def transform_invitation_results(results)
|
|
794
|
+
return results unless results.is_a?(Array)
|
|
795
|
+
results.map { |r| transform_invitation_result(r) }
|
|
796
|
+
end
|
|
797
|
+
|
|
524
798
|
def build_connection
|
|
525
799
|
Faraday.new(@base_url) do |conn|
|
|
526
800
|
conn.request :json
|
|
@@ -563,5 +837,17 @@ module Vortex
|
|
|
563
837
|
hex = bytes.unpack1('H*')
|
|
564
838
|
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
565
839
|
end
|
|
840
|
+
|
|
841
|
+
# Deprecated methods for backward compatibility
|
|
842
|
+
|
|
843
|
+
# @deprecated Use get_invitations_by_scope instead
|
|
844
|
+
def get_invitations_by_group(group_type, group)
|
|
845
|
+
get_invitations_by_scope(group_type, group)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# @deprecated Use delete_invitations_by_scope instead
|
|
849
|
+
def delete_invitations_by_group(group_type, group)
|
|
850
|
+
delete_invitations_by_scope(group_type, group)
|
|
851
|
+
end
|
|
566
852
|
end
|
|
567
|
-
end
|
|
853
|
+
end
|
data/lib/vortex/rails.rb
CHANGED
|
@@ -181,9 +181,9 @@ module Vortex
|
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# Get invitations by group
|
|
184
|
-
# GET /api/vortex/invitations/by-
|
|
185
|
-
def
|
|
186
|
-
Vortex::Rails.logger.debug("Vortex::Rails::Controller#
|
|
184
|
+
# GET /api/vortex/invitations/by-scope/:scope_type/:scope
|
|
185
|
+
def get_invitations_by_scope
|
|
186
|
+
Vortex::Rails.logger.debug("Vortex::Rails::Controller#get_invitations_by_scope invoked")
|
|
187
187
|
|
|
188
188
|
user = authenticate_vortex_user
|
|
189
189
|
return render_unauthorized('Authentication required') unless user
|
|
@@ -192,19 +192,19 @@ module Vortex
|
|
|
192
192
|
return render_forbidden('Not authorized to get group invitations')
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
scope_type = params[:scope_type]
|
|
196
|
+
scope = params[:scope]
|
|
197
197
|
|
|
198
|
-
invitations = vortex_client.
|
|
198
|
+
invitations = vortex_client.get_invitations_by_scope(scope_type, scope)
|
|
199
199
|
render json: { invitations: invitations }
|
|
200
200
|
rescue Vortex::VortexError => e
|
|
201
201
|
render_server_error("Failed to get group invitations: #{e.message}")
|
|
202
202
|
end
|
|
203
203
|
|
|
204
204
|
# Delete invitations by group
|
|
205
|
-
# DELETE /api/vortex/invitations/by-
|
|
206
|
-
def
|
|
207
|
-
Vortex::Rails.logger.debug("Vortex::Rails::Controller#
|
|
205
|
+
# DELETE /api/vortex/invitations/by-scope/:scope_type/:scope
|
|
206
|
+
def delete_invitations_by_scope
|
|
207
|
+
Vortex::Rails.logger.debug("Vortex::Rails::Controller#delete_invitations_by_scope invoked")
|
|
208
208
|
|
|
209
209
|
user = authenticate_vortex_user
|
|
210
210
|
return render_unauthorized('Authentication required') unless user
|
|
@@ -213,10 +213,10 @@ module Vortex
|
|
|
213
213
|
return render_forbidden('Not authorized to delete group invitations')
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
scope_type = params[:scope_type]
|
|
217
|
+
scope = params[:scope]
|
|
218
218
|
|
|
219
|
-
vortex_client.
|
|
219
|
+
vortex_client.delete_invitations_by_scope(scope_type, scope)
|
|
220
220
|
render json: { success: true }
|
|
221
221
|
rescue Vortex::VortexError => e
|
|
222
222
|
render_server_error("Failed to delete group invitations: #{e.message}")
|
|
@@ -296,8 +296,8 @@ module Vortex
|
|
|
296
296
|
get 'invitations/:invitation_id', action: 'get_invitation'
|
|
297
297
|
delete 'invitations/:invitation_id', action: 'revoke_invitation'
|
|
298
298
|
post 'invitations/accept', action: 'accept_invitations'
|
|
299
|
-
get 'invitations/by-
|
|
300
|
-
delete 'invitations/by-
|
|
299
|
+
get 'invitations/by-scope/:scope_type/:scope', action: 'get_invitations_by_scope'
|
|
300
|
+
delete 'invitations/by-scope/:scope_type/:scope', action: 'delete_invitations_by_scope'
|
|
301
301
|
post 'invitations/:invitation_id/reinvite', action: 'reinvite'
|
|
302
302
|
end
|
|
303
303
|
end
|
data/lib/vortex/sinatra.rb
CHANGED
|
@@ -142,8 +142,8 @@ module Vortex
|
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
# Get invitations by group
|
|
145
|
-
# GET /api/vortex/invitations/by-
|
|
146
|
-
app.get '/api/vortex/invitations/by-
|
|
145
|
+
# GET /api/vortex/invitations/by-scope/:scope_type/:scope
|
|
146
|
+
app.get '/api/vortex/invitations/by-scope/:scope_type/:scope' do
|
|
147
147
|
with_vortex_error_handling do
|
|
148
148
|
user = authenticate_vortex_user
|
|
149
149
|
return render_unauthorized('Authentication required') unless user
|
|
@@ -152,17 +152,17 @@ module Vortex
|
|
|
152
152
|
return render_forbidden('Not authorized to get group invitations')
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
scope_type = params['scope_type']
|
|
156
|
+
scope = params['scope']
|
|
157
157
|
|
|
158
|
-
invitations = vortex_client.
|
|
158
|
+
invitations = vortex_client.get_invitations_by_scope(scope_type, scope)
|
|
159
159
|
render_json({ invitations: invitations })
|
|
160
160
|
end
|
|
161
161
|
end
|
|
162
162
|
|
|
163
163
|
# Delete invitations by group
|
|
164
|
-
# DELETE /api/vortex/invitations/by-
|
|
165
|
-
app.delete '/api/vortex/invitations/by-
|
|
164
|
+
# DELETE /api/vortex/invitations/by-scope/:scope_type/:scope
|
|
165
|
+
app.delete '/api/vortex/invitations/by-scope/:scope_type/:scope' do
|
|
166
166
|
with_vortex_error_handling do
|
|
167
167
|
user = authenticate_vortex_user
|
|
168
168
|
return render_unauthorized('Authentication required') unless user
|
|
@@ -171,10 +171,10 @@ module Vortex
|
|
|
171
171
|
return render_forbidden('Not authorized to delete group invitations')
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
scope_type = params['scope_type']
|
|
175
|
+
scope = params['scope']
|
|
176
176
|
|
|
177
|
-
vortex_client.
|
|
177
|
+
vortex_client.delete_invitations_by_scope(scope_type, scope)
|
|
178
178
|
render_json({ success: true })
|
|
179
179
|
end
|
|
180
180
|
end
|
data/lib/vortex/types.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Vortex
|
|
|
19
19
|
name: String # Required: Group name
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# InvitationScope structure from API responses
|
|
23
23
|
# This matches the MemberGroups table structure from the API
|
|
24
24
|
# @example
|
|
25
25
|
# {
|
|
@@ -44,12 +44,14 @@ module Vortex
|
|
|
44
44
|
# {
|
|
45
45
|
# email: 'user@example.com',
|
|
46
46
|
# phone: '+1234567890', # Optional
|
|
47
|
-
# name: 'John Doe'
|
|
47
|
+
# name: 'John Doe', # Optional
|
|
48
|
+
# is_existing: true # Optional - whether user was already registered
|
|
48
49
|
# }
|
|
49
50
|
ACCEPT_USER = {
|
|
50
|
-
email: String,
|
|
51
|
-
phone: String,
|
|
52
|
-
name: String
|
|
51
|
+
email: String, # Optional but either email or phone must be provided
|
|
52
|
+
phone: String, # Optional but either email or phone must be provided
|
|
53
|
+
name: String, # Optional
|
|
54
|
+
is_existing: 'Boolean (true/false/nil)' # Optional - true if existing user, false if new signup, nil if unknown
|
|
53
55
|
}.freeze
|
|
54
56
|
|
|
55
57
|
# Invitation structure from API responses
|
data/lib/vortex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vortex-ruby-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vortex Software
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|