bfocus 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 427efa66516864dfd256464bb24bbda1dad9dae5d4c4df4da9b7e5f1c4cd7d7c
4
- data.tar.gz: aff4271f81c03da6ffb70e16860e594f7e6428d582d79ea56fe31cfd783f472e
3
+ metadata.gz: 6989b99e0788e360a77f8853de9a52f5e9628db20515a2ccbb9c57fd173e4c23
4
+ data.tar.gz: 3e3a8766862577d33ddf9e8bb3959b380fbd995f43e97688bd7f8bf05d3a06c2
5
5
  SHA512:
6
- metadata.gz: 837084970aaf2a9ee8e4f383c74787021773a0057081ea60f8adf64512813a01abf860029d3333f5d0b624c540bfc3333367c6901f4677b8439f3b1c8d159505
7
- data.tar.gz: f7a941f57940c96fc38c8d7b71dc97a545f4e8edf03d06b63c4b17da7a0c8cd6b8cc13b5220a5d1f4530fa38c2f021e88c41343831492aa11bb7a096a95ba7ab
6
+ metadata.gz: 337c578cf5bde255220540a83297d298a89a08f97e31f72a274a25b7614c5c9b68b9cd782a95f39382ed3b858019d42a0dd16a4fc66483a281e6e43bce1e2b18
7
+ data.tar.gz: 5291a2a3e6b1cd5e6fd6bd7bf9d5dfb2e3abc12aa14cccc70b9fc1d2400eba5980e56edc62e2ccfa988acff36c3afb6bff13f9180cbb2e1c27f610bdc2c45716
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # bfocus
2
2
 
3
- SDK oficial em **Ruby** da API pública do [bFocus](https://bfocus.com.br): clientes, produtos,
4
- release notes, base de conhecimento e agentes de IA.
3
+ SDK oficial em **Ruby** da API pública do [bFocus](https://bfocus.com.br): clientes, pessoas,
4
+ produtos, release notes, base de conhecimento e agentes de IA.
5
5
 
6
6
  Zero dependências de runtime (só biblioteca padrão: `net/http`, `json`, `openssl`,
7
7
  `securerandom`) · Ruby 3.0+ · novas tentativas e idempotência automáticas.
@@ -93,8 +93,8 @@ Construir o cliente não faz nenhuma chamada de rede. O cliente não guarda esta
93
93
  escrita aceitam `idempotency_key:` (veja [Novas tentativas](#novas-tentativas-e-idempotência)).
94
94
  - Datas (`updated_since`) aceitam `Time`/`DateTime` — convertidos para ISO 8601 em UTC com `Z` —,
95
95
  `Date` (meia-noite UTC) ou string, que passa como veio.
96
- - Hashes de entrada (`custom_fields`, itens do `batch_upsert`, `history`) aceitam chaves símbolo
97
- ou string.
96
+ - Hashes de entrada (`custom_fields`, itens do `batch_upsert`/`customers.batch`/`people.batch`,
97
+ `history`) aceitam chaves símbolo ou string.
98
98
 
99
99
  ## Clientes
100
100
 
@@ -139,6 +139,143 @@ client.customers.interactions.list_all("ERP 1042").each do |i|
139
139
  end
140
140
  ```
141
141
 
142
+ ### Identificadores extras
143
+
144
+ Ligue o id de **outro sistema seu** (CRM, loja, app…) ao mesmo cadastro: depois disso o cliente (ou
145
+ a pessoa) é encontrado por qualquer um dos ids. É idempotente (ligar de novo não muda nada). Se o id
146
+ já pertence a outro cadastro, a API recusa com `Bfocus::ConflictError` e `code == "IDENTIFIER_IN_USE"`.
147
+
148
+ ```ruby
149
+ cliente = client.customers.identifiers.add("erp-1042", "crm-88", label: "CRM") # label é opcional
150
+ cliente["identifiers"] # => [{"external_id" => "crm-88", "label" => "CRM", "source" => "api"}]
151
+ client.customers.identifiers.remove("erp-1042", "crm-88")
152
+
153
+ client.people.identifiers.add("app-77", "crm-p5")
154
+ client.people.identifiers.remove("app-77", "crm-p5")
155
+ ```
156
+
157
+ ## Pessoas
158
+
159
+ As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
160
+ é o mesmo `user_external_id` que você assina para o [widget](#identidade-do-widget).
161
+
162
+ ```ruby
163
+ pessoa = client.people.upsert(
164
+ "erp-1042", "app-77",
165
+ name: "Paula Reis", email: "paula@padaria.example", role: "Financeiro", is_primary: true,
166
+ extra_emails: ["paula.reis@pessoal.example"]
167
+ )
168
+ pessoa["status"] # => "created", "updated" ou "unchanged"
169
+
170
+ client.people.list("erp-1042") # todas as pessoas do cliente
171
+ client.people.delete("erp-1042", "app-77") # retira o acesso; a pessoa continua no histórico
172
+ client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
173
+ ```
174
+
175
+ - **Nunca duplica.** O e-mail (ou o telefone) acha a pessoa que já chegou por e-mail, pelo widget
176
+ ou por outro sistema, e ela é **adotada** (ganha o seu `external_id`).
177
+ - A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
178
+ - Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
179
+ - Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
180
+ (contato principal), `extra_emails`, `extra_phones`.
181
+
182
+ ## Lotes — `customers.batch` e `people.batch`
183
+
184
+ Até **500 itens por chamada** (`Bfocus::BATCH_MAX`). Acima disso a SDK lança `ArgumentError` antes
185
+ de qualquer requisição — ela não divide sozinha, porque o `index` de cada resultado é a posição no
186
+ lote que **você** enviou. Divida assim:
187
+
188
+ ```ruby
189
+ clientes = meus_clientes.map do |c|
190
+ { external_id: "erp-#{c.id}", name: c.nome, document: c.cnpj, email: c.email }
191
+ end
192
+
193
+ clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
194
+ resultado = client.customers.batch(fatia)
195
+ resultado["results"].each do |item|
196
+ next unless item["status"] == "error"
197
+
198
+ warn "#{fatia[item['index']][:external_id]}: #{item['error']} (HTTP #{item['code']})"
199
+ end
200
+ end
201
+ ```
202
+
203
+ - Cada item de `customers.batch` é `external_id` + os campos do `customers.upsert`.
204
+ - Cada item de `people.batch` é **plano**: `customer_external_id` + `external_id` (da pessoa) + os
205
+ campos do `people.upsert`.
206
+ - Retorno: `results` (um por item: `index`, `status` — `created`/`updated`/`unchanged`/`error` —,
207
+ `external_id`, `merged_into`, `error` com o código estável e `code` com o status HTTP do item) +
208
+ `summary` (`created`, `updated`, `unchanged`, `error`).
209
+ - **Um erro não desfaz os outros**: confira `summary["error"]` e registre os itens com erro.
210
+ - Lista vazia devolve o resultado zerado sem fazer requisição.
211
+ - `idempotency_key:` vale para o lote inteiro (um lote = uma chamada).
212
+
213
+ ## Sincronizar clientes e usuários do seu sistema
214
+
215
+ **Ids com o prefixo do sistema, sem `:`.** Use `-` como separador — `erp-1042` para clientes,
216
+ `app-77` para pessoas — ou UUIDs puros: vários sistemas seus convivem no mesmo bFocus sem colisão. A
217
+ assinatura do widget recusa `:` (é o separador dela), então não use `:` em nenhum `external_id` de
218
+ cliente ou pessoa.
219
+
220
+ **Carga inicial (no deploy):** clientes em fatias de 500 → vincule cada um ao produto → pessoas em
221
+ fatias de 500.
222
+
223
+ ```ruby
224
+ def carga_inicial(client, clientes, usuarios)
225
+ clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
226
+ r = client.customers.batch(fatia.map { |c| { external_id: "erp-#{c.id}", name: c.nome } })
227
+ registrar_erros(r, fatia) if r["summary"]["error"].positive?
228
+ end
229
+
230
+ clientes.each { |c| client.customers.products.attach("erp-#{c.id}", "erp-cloud") }
231
+
232
+ usuarios.each_slice(Bfocus::BATCH_MAX) do |fatia|
233
+ itens = fatia.map do |u|
234
+ { customer_external_id: "erp-#{u.cliente_id}", external_id: "app-#{u.id}",
235
+ name: u.nome, email: u.email }
236
+ end
237
+ r = client.people.batch(itens)
238
+ registrar_erros(r, fatia) if r["summary"]["error"].positive?
239
+ end
240
+ end
241
+ ```
242
+
243
+ **Depois, no dia a dia**, espelhe cada evento do seu sistema:
244
+
245
+ | No seu sistema | No bFocus |
246
+ | --- | --- |
247
+ | criou/alterou cliente | `customers.upsert` |
248
+ | criou/alterou usuário | `people.upsert` |
249
+ | excluiu/desativou usuário | `people.delete` |
250
+ | excluiu cliente | `customers.delete` |
251
+
252
+ Vincule o cliente ao produto com `customers.products.attach`. Se a resposta trouxer `merged_into`,
253
+ o cadastro foi unificado em outro: atualize o id do seu lado.
254
+
255
+ **Nunca bloqueie a requisição do seu usuário esperando o bFocus.** Enfileire (job/outbox) e tente de
256
+ novo com backoff; a SDK já repete 429/5xx com a mesma `Idempotency-Key`, e a fila cobre
257
+ indisponibilidades longas.
258
+
259
+ ```ruby
260
+ # app/jobs/bfocus_sync_usuario_job.rb (ActiveJob; o mesmo vale para Sidekiq etc.)
261
+ class BfocusSyncUsuarioJob < ApplicationJob
262
+ retry_on Bfocus::NetworkError, Bfocus::RateLimitError, Bfocus::ServerError,
263
+ wait: :polynomially_longer, attempts: 10
264
+
265
+ def perform(usuario_id)
266
+ u = Usuario.find(usuario_id)
267
+ if u.ativo?
268
+ BFOCUS.people.upsert("erp-#{u.cliente_id}", "app-#{u.id}", name: u.nome, email: u.email)
269
+ else
270
+ BFOCUS.people.delete("erp-#{u.cliente_id}", "app-#{u.id}")
271
+ end
272
+ end
273
+ end
274
+
275
+ # no model, depois de salvar — a requisição do usuário não espera o bFocus:
276
+ after_commit { BfocusSyncUsuarioJob.perform_later(id) }
277
+ ```
278
+
142
279
  ## Produtos
143
280
 
144
281
  ```ruby
@@ -273,6 +410,11 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
273
410
  | Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
274
411
  | Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
275
412
  | Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
413
+ | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id` |
414
+ | Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
415
+ | Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
416
+ | Identificadores da pessoa (`people.identifiers.add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
417
+ | Lote (`customers.batch`, `people.batch`) | `results` (lista de `{index, status, external_id, merged_into, error, code}`; `status` ∈ `created`/`updated`/`unchanged`/`error`), `summary` (`{created, updated, unchanged, error}`) |
276
418
  | Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
277
419
  | Release note (`release_notes.*`) | `id`, `product`, `version`, `title`, `description_html`, `audience`, `is_published`, `require_ack_internal`, `require_ack_external`, `published_at`, `created_at`, `updated_at` |
278
420
  | Artigo — resumo (`kb.articles.list`, `list_all`) | `id`, `external_id`, `product`, `title`, `excerpt`, `status`, `origin`, `published_at`, `created_at`, `updated_at` |
@@ -374,6 +516,28 @@ assinatura = Bfocus.sign_widget_identity(
374
516
  # que abre o widget.
375
517
  ```
376
518
 
519
+ ### Identidade do widget v2 (com validade)
520
+
521
+ A v2 carimba o instante na assinatura, então uma assinatura vazada deixa de valer sozinha:
522
+
523
+ ```ruby
524
+ user_hash = Bfocus.sign_widget_identity_v2(
525
+ ENV.fetch("BFOCUS_WIDGET_SECRET"),
526
+ "app-77", # user_external_id: sem ":" (é o separador; a API recusa)
527
+ "erp-1042" # customer_external_id: a empresa dele
528
+ )
529
+ # => "v2.<ts>.<hex>", ex.: "v2.1789000000.9c1e…"
530
+
531
+ # instante explícito (segundos unix, não ms; ou Time) — útil em testes:
532
+ Bfocus.sign_widget_identity_v2(segredo, "app-77", "erp-1042", now: 1_789_000_000)
533
+ ```
534
+
535
+ - `hex` = HMAC-SHA256 em hex minúsculo de `"v2:<ts>:<user_external_id>:<customer_external_id>"`.
536
+ - Vale de **7 dias atrás até 5 minutos à frente**: gere a cada renderização da página, **nunca
537
+ guarde**. Vai no mesmo lugar da v1 (`userHash` do widget).
538
+ - O id do usuário não pode ter `:` (`ArgumentError`) — use `-` como separador (`app-77`).
539
+ - A v1 continua aceita.
540
+
377
541
  ## Versões
378
542
 
379
543
  **Fixe a versão exata** (`gem "bfocus", "0.1.0"` no `Gemfile`) e suba de uma versão para a outra
data/lib/bfocus/client.rb CHANGED
@@ -11,8 +11,10 @@ module Bfocus
11
11
  # client.customers.upsert("ERP 1042", name: "Padaria Estrela")
12
12
  class Client
13
13
  # @return [Resources::Customers] clientes (empresas), com `.contacts`, `.products` e
14
- # `.interactions`.
14
+ # `.interactions` e `.identifiers`.
15
15
  attr_reader :customers
16
+ # @return [Resources::People] pessoas (usuários) dos clientes, com `.identifiers`.
17
+ attr_reader :people
16
18
  # @return [Resources::Products] catálogo de produtos.
17
19
  attr_reader :products
18
20
  # @return [Resources::ReleaseNotes] release notes por produto.
@@ -52,6 +54,7 @@ module Bfocus
52
54
  @transport = Transport.new(api_key, base_url: url, timeout: timeout,
53
55
  max_retries: max_retries, sleeper: sleeper)
54
56
  @customers = Resources::Customers.new(@transport)
57
+ @people = Resources::People.new(@transport)
55
58
  @products = Resources::Products.new(@transport)
56
59
  @release_notes = Resources::ReleaseNotes.new(@transport)
57
60
  @kb = Resources::KnowledgeBase.new(@transport)
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bfocus
4
- # Recursos da API pública: `customers`, `products`, `release_notes`, `kb`, `ai_agents`.
4
+ # Máximo de itens por chamada de `customers.batch` e `people.batch`. Acima disso a SDK lança
5
+ # `ArgumentError` antes de qualquer requisição — ela NÃO divide sozinha, porque o `"index"` de
6
+ # cada resultado é a posição no lote que você enviou. Divida com `each_slice(Bfocus::BATCH_MAX)`.
7
+ BATCH_MAX = 500
8
+
9
+ # Recursos da API pública: `customers`, `people`, `products`, `release_notes`, `kb`, `ai_agents`.
5
10
  #
6
11
  # Convenções (iguais em todos os métodos):
7
12
  #
@@ -61,6 +66,42 @@ module Bfocus
61
66
  def compact(fields)
62
67
  Codec.compact(fields)
63
68
  end
69
+
70
+ # Resultado de um lote vazio (sem requisição).
71
+ def empty_batch_result
72
+ { "results" => [], "summary" => { "created" => 0, "updated" => 0, "unchanged" => 0, "error" => 0 } }
73
+ end
74
+
75
+ # Valida a lista de um lote (`customers.batch`/`people.batch`) antes de qualquer
76
+ # requisição e devolve os itens como Hash de chaves string (sem {UNSET}).
77
+ # @raise [TypeError] não é lista ou item não é Hash.
78
+ # @raise [ArgumentError] mais de {Bfocus::BATCH_MAX} itens.
79
+ def batch_items(items, op)
80
+ if items.is_a?(Hash) || !items.respond_to?(:each_with_index) || !items.respond_to?(:size)
81
+ raise TypeError, "#{op}: items precisa ser uma lista de Hash."
82
+ end
83
+ if items.size > BATCH_MAX
84
+ raise ArgumentError, "#{op} aceita até #{BATCH_MAX} itens por chamada (recebeu #{items.size}); " \
85
+ "divida em lotes de #{BATCH_MAX}."
86
+ end
87
+
88
+ items.each_with_index.map do |item, index|
89
+ raise TypeError, "#{op}: items[#{index}] precisa ser um Hash." unless item.is_a?(Hash)
90
+
91
+ item.each_with_object({}) do |(key, value), out|
92
+ out[key.to_s] = value unless value.equal?(UNSET)
93
+ end
94
+ end
95
+ end
96
+
97
+ # Campo de id obrigatório num item de lote: String (ou número) não vazia.
98
+ # @raise [ArgumentError]
99
+ def batch_id!(item, field, op, index)
100
+ value = item[field]
101
+ return value unless value.nil? || value.to_s.empty?
102
+
103
+ raise ArgumentError, "#{op}: items[#{index}].#{field} é obrigatório."
104
+ end
64
105
  end
65
106
  end
66
107
  end
@@ -101,8 +101,37 @@ module Bfocus
101
101
  end
102
102
  end
103
103
 
104
+ # Identificadores extras de um cliente — `client.customers.identifiers`: liga o id de OUTRO
105
+ # sistema seu (CRM, loja…) ao mesmo cadastro, que passa a ser encontrado por qualquer um deles.
106
+ #
107
+ # Retorno: o cliente + `"identifiers"` (lista de `{"external_id", "label", "source"}`).
108
+ # Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
109
+ class CustomerIdentifiers < Base
110
+ # Liga `extra_id` ao cliente (idempotente).
111
+ # `PUT /customers/{external_id}/identifiers/{extra_id}`
112
+ #
113
+ # @param label [String, nil] rótulo livre (ex.: `"CRM"`). Não informado = sem corpo.
114
+ # @return [Hash] o cliente com `"identifiers"`.
115
+ def add(external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
116
+ ext = segment(external_id, "external_id")
117
+ extra = segment(extra_id, "extra_id")
118
+ body = label.equal?(UNSET) ? nil : { "label" => label }
119
+ call("PUT", "/customers/#{ext}/identifiers/#{extra}",
120
+ body: body, idempotency_key: idempotency_key, timeout: timeout)
121
+ end
122
+
123
+ # Desliga `extra_id` do cliente. `DELETE /customers/{external_id}/identifiers/{extra_id}`
124
+ # @return [Hash] o cliente com `"identifiers"`.
125
+ def remove(external_id, extra_id, idempotency_key: nil, timeout: nil)
126
+ ext = segment(external_id, "external_id")
127
+ extra = segment(extra_id, "extra_id")
128
+ call("DELETE", "/customers/#{ext}/identifiers/#{extra}",
129
+ idempotency_key: idempotency_key, timeout: timeout)
130
+ end
131
+ end
132
+
104
133
  # Clientes (empresas) — `client.customers`. Sub-recursos: {#contacts}, {#products},
105
- # {#interactions}.
134
+ # {#interactions}, {#identifiers}.
106
135
  #
107
136
  # Cliente: `"id"`, `"external_id"`, `"name"`, `"document"`, `"email"`, `"phone"`,
108
137
  # `"website"`, `"notes"`, `"custom_fields"` (lista de `{"key", "label", "type", "value",
@@ -114,12 +143,15 @@ module Bfocus
114
143
  attr_reader :products
115
144
  # @return [CustomerInteractions]
116
145
  attr_reader :interactions
146
+ # @return [CustomerIdentifiers]
147
+ attr_reader :identifiers
117
148
 
118
149
  def initialize(transport)
119
150
  super
120
151
  @contacts = CustomerContacts.new(transport)
121
152
  @products = CustomerProducts.new(transport)
122
153
  @interactions = CustomerInteractions.new(transport)
154
+ @identifiers = CustomerIdentifiers.new(transport)
123
155
  end
124
156
 
125
157
  # Cria ou atualiza um cliente pelo `external_id` do seu sistema. `PUT /customers/{external_id}`
@@ -137,6 +169,31 @@ module Bfocus
137
169
  call("PUT", "/customers/#{ext}", body: body, idempotency_key: idempotency_key, timeout: timeout)
138
170
  end
139
171
 
172
+ # Cria/atualiza até {Bfocus::BATCH_MAX} (500) clientes numa chamada. `POST /customers/batch`
173
+ #
174
+ # Cada item (Hash, chaves string ou símbolo) tem `external_id` (obrigatório) e os mesmos
175
+ # campos do {#upsert}, com a mesma regra: ausente = não muda; `nil` limpa.
176
+ #
177
+ # A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
178
+ # requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
179
+ # a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
180
+ # resultado zerado sem chamar a API.
181
+ #
182
+ # @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
183
+ # "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}` —
184
+ # `"status"` ∈ `created`/`updated`/`unchanged`/`error`; `"error"` é o código estável e
185
+ # `"code"` o status HTTP do item.
186
+ # @raise [ArgumentError] mais de 500 itens ou item sem `external_id`.
187
+ # @raise [TypeError] `items` não é lista de Hash.
188
+ def batch(items, idempotency_key: nil, timeout: nil)
189
+ list = batch_items(items, "customers.batch")
190
+ return empty_batch_result if list.empty?
191
+
192
+ list.each_with_index { |item, index| batch_id!(item, "external_id", "customers.batch", index) }
193
+ call("POST", "/customers/batch",
194
+ body: { "items" => list }, idempotency_key: idempotency_key, timeout: timeout)
195
+ end
196
+
140
197
  # Um cliente. `GET /customers/{external_id}`
141
198
  # @return [Hash]
142
199
  def get(external_id, timeout: nil)
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bfocus
4
+ module Resources
5
+ # Identificadores extras de uma pessoa — `client.people.identifiers`: liga o id de OUTRO
6
+ # sistema seu ao mesmo cadastro.
7
+ #
8
+ # Retorno: `{"external_id", "identifiers" => [{"external_id", "label", "source"}, …]}`.
9
+ # Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
10
+ class PersonIdentifiers < Base
11
+ # Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
12
+ #
13
+ # @param label [String, nil] rótulo livre. Não informado = sem corpo.
14
+ # @return [Hash]
15
+ def add(person_external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
16
+ pid = segment(person_external_id, "person_external_id")
17
+ extra = segment(extra_id, "extra_id")
18
+ body = label.equal?(UNSET) ? nil : { "label" => label }
19
+ call("PUT", "/people/#{pid}/identifiers/#{extra}",
20
+ body: body, idempotency_key: idempotency_key, timeout: timeout)
21
+ end
22
+
23
+ # Desliga `extra_id` da pessoa. `DELETE /people/{person_external_id}/identifiers/{extra_id}`
24
+ # @return [Hash]
25
+ def remove(person_external_id, extra_id, idempotency_key: nil, timeout: nil)
26
+ pid = segment(person_external_id, "person_external_id")
27
+ extra = segment(extra_id, "extra_id")
28
+ call("DELETE", "/people/#{pid}/identifiers/#{extra}",
29
+ idempotency_key: idempotency_key, timeout: timeout)
30
+ end
31
+ end
32
+
33
+ # Pessoas (usuários do seu sistema) de um cliente — `client.people`. Sub-recurso:
34
+ # {#identifiers}.
35
+ #
36
+ # Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
37
+ # `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
38
+ # `"customer_external_id"`. O `upsert` devolve também `"status"`
39
+ # (`"created"`/`"updated"`/`"unchanged"`).
40
+ #
41
+ # O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
42
+ # pode ter `:` (use outro separador, ex.: `"app-77"`).
43
+ class People < Base
44
+ # @return [PersonIdentifiers]
45
+ attr_reader :identifiers
46
+
47
+ def initialize(transport)
48
+ super
49
+ @identifiers = PersonIdentifiers.new(transport)
50
+ end
51
+
52
+ # Cria ou atualiza uma pessoa do cliente pelo `external_id` dela.
53
+ # `PUT /customers/{customer_external_id}/people/{person_external_id}`
54
+ #
55
+ # Só o que vier muda; `nil` limpa. O e-mail (ou telefone) acha a pessoa que já chegou por
56
+ # outro caminho e ela é adotada, nunca duplicada; se ela estava em outro cliente, é
57
+ # transferida. `access: true` devolve o acesso retirado por {#delete}.
58
+ #
59
+ # @param access [Boolean] pode abrir chamados/usar o widget.
60
+ # @param is_primary [Boolean] contato principal do cliente.
61
+ # @param extra_emails [Array<String>] e-mails adicionais.
62
+ # @param extra_phones [Array<String>] telefones adicionais.
63
+ # @return [Hash] a pessoa + `"status"`.
64
+ def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
65
+ role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
66
+ idempotency_key: nil, timeout: nil)
67
+ cid = segment(customer_external_id, "customer_external_id")
68
+ pid = segment(person_external_id, "person_external_id")
69
+ person = compact(
70
+ "name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
71
+ "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones
72
+ )
73
+ call("PUT", "/customers/#{cid}/people/#{pid}",
74
+ body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
75
+ end
76
+
77
+ # Pessoas do cliente. `GET /customers/{customer_external_id}/people`
78
+ # @return [Array<Hash>]
79
+ def list(customer_external_id, timeout: nil)
80
+ call("GET", "/customers/#{segment(customer_external_id, 'customer_external_id')}/people",
81
+ timeout: timeout)
82
+ end
83
+
84
+ # Retira o acesso da pessoa (ela continua no histórico).
85
+ # `DELETE /customers/{customer_external_id}/people/{person_external_id}`
86
+ # @return [Hash] a pessoa, com `"access" => false`.
87
+ def delete(customer_external_id, person_external_id, idempotency_key: nil, timeout: nil)
88
+ cid = segment(customer_external_id, "customer_external_id")
89
+ pid = segment(person_external_id, "person_external_id")
90
+ call("DELETE", "/customers/#{cid}/people/#{pid}",
91
+ idempotency_key: idempotency_key, timeout: timeout)
92
+ end
93
+
94
+ # Cria/atualiza até {Bfocus::BATCH_MAX} (500) pessoas numa chamada. `POST /people/batch`
95
+ #
96
+ # Cada item (Hash plano, chaves string ou símbolo): `customer_external_id` e `external_id`
97
+ # (da pessoa), ambos obrigatórios, + os campos do {#upsert} (ausente = não muda; `nil`
98
+ # limpa). No fio vira `{"customer_external_id" => …, "person" => {"external_id" => …, …}}`.
99
+ #
100
+ # A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
101
+ # requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
102
+ # a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
103
+ # resultado zerado sem chamar a API.
104
+ #
105
+ # @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
106
+ # "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}`
107
+ # @raise [ArgumentError] mais de 500 itens ou item sem `customer_external_id`/`external_id`.
108
+ # @raise [TypeError] `items` não é lista de Hash.
109
+ def batch(items, idempotency_key: nil, timeout: nil)
110
+ list = batch_items(items, "people.batch")
111
+ return empty_batch_result if list.empty?
112
+
113
+ wire = list.each_with_index.map do |item, index|
114
+ customer = batch_id!(item, "customer_external_id", "people.batch", index)
115
+ batch_id!(item, "external_id", "people.batch", index)
116
+ { "customer_external_id" => customer, "person" => item.reject { |key, _| key == "customer_external_id" } }
117
+ end
118
+ call("POST", "/people/batch",
119
+ body: { "items" => wire }, idempotency_key: idempotency_key, timeout: timeout)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -3,5 +3,5 @@
3
3
  module Bfocus
4
4
  # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa esta linha por regex e o
5
5
  # `bfocus.gemspec` lê daqui — mantenha o formato exato `VERSION = "X.Y.Z"`.
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
data/lib/bfocus/widget.rb CHANGED
@@ -27,4 +27,53 @@ module Bfocus
27
27
  message = "v1:".b + utf8.call(user_external_id) + ":".b + utf8.call(customer_external_id)
28
28
  OpenSSL::HMAC.hexdigest("SHA256", utf8.call(secret), message)
29
29
  end
30
+
31
+ # Identidade do widget **v2 (com validade)**: como {sign_widget_identity}, mas carimbada com o
32
+ # instante. Roda no seu backend, sem rede e sem chave de API.
33
+ #
34
+ # Devolve `"v2.<ts>.<hex>"`: `ts` = segundos unix inteiros do instante (padrão: agora) e `hex`
35
+ # = HMAC-SHA256, em hexadecimal minúsculo, de
36
+ # `"v2:" + ts + ":" + user_external_id + ":" + customer_external_id` (UTF-8). A API aceita de
37
+ # 7 dias atrás até 5 minutos à frente — gere a cada renderização da página, nunca guarde. Vai
38
+ # no mesmo lugar da v1 (`userHash` do widget); a v1 continua aceita.
39
+ #
40
+ # @param secret [String] segredo de identidade do widget (painel do bFocus).
41
+ # @param user_external_id [String] `external_id` do usuário (a pessoa) no seu sistema — sem
42
+ # `:` (é o separador; a API recusa).
43
+ # @param customer_external_id [String] `external_id` do cliente (empresa). Prefira `-` a `:` nos
44
+ # seus ids (ex.: `"erp-1042"`).
45
+ # @param now [Integer, Time, nil] instante da assinatura: segundos unix (não ms) ou `Time`.
46
+ # Padrão: agora.
47
+ # @return [String] `"v2.<ts>.<64 hex>"`.
48
+ # @raise [ArgumentError] segredo vazio, id `nil`, `:` no id do usuário, instante negativo ou de
49
+ # tipo inválido.
50
+ #
51
+ # @example
52
+ # Bfocus.sign_widget_identity_v2(ENV.fetch("BFOCUS_WIDGET_SECRET"), "app-77", "erp-1042")
53
+ # # => "v2.<ts>.<hex>"
54
+ def self.sign_widget_identity_v2(secret, user_external_id, customer_external_id, now: nil)
55
+ raise ArgumentError, "sign_widget_identity_v2: secret é obrigatório." if secret.nil? || secret.to_s.empty?
56
+ raise ArgumentError, "sign_widget_identity_v2: user_external_id é obrigatório." if user_external_id.nil?
57
+ raise ArgumentError, "sign_widget_identity_v2: customer_external_id é obrigatório." if customer_external_id.nil?
58
+ if user_external_id.to_s.include?(":")
59
+ raise ArgumentError, "sign_widget_identity_v2: user_external_id não pode ter ':' (é o separador; " \
60
+ "a API recusa) — use outro, ex.: \"app-77\": #{user_external_id.to_s.inspect}"
61
+ end
62
+
63
+ ts = case now
64
+ when nil then Time.now.to_i
65
+ when Time then now.to_i
66
+ when Integer then now
67
+ else
68
+ raise ArgumentError, "sign_widget_identity_v2: now precisa ser Integer (segundos unix) ou Time."
69
+ end
70
+ raise ArgumentError, "sign_widget_identity_v2: now não pode ser negativo (#{ts})." if ts.negative?
71
+
72
+ utf8 = lambda do |value|
73
+ text = value.to_s
74
+ (text.encoding == ::Encoding::BINARY ? text : text.encode(::Encoding::UTF_8)).b
75
+ end
76
+ message = "v2:#{ts}:".b + utf8.call(user_external_id) + ":".b + utf8.call(customer_external_id)
77
+ "v2.#{ts}.#{OpenSSL::HMAC.hexdigest('SHA256', utf8.call(secret), message)}"
78
+ end
30
79
  end
data/lib/bfocus.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # SDK oficial em Ruby da API pública do bFocus: clientes, produtos, release notes, base de
3
+ # SDK oficial em Ruby da API pública do bFocus: clientes, pessoas, produtos, release notes, base de
4
4
  # conhecimento e agentes de IA. Só biblioteca padrão (net/http, json, openssl, securerandom).
5
5
  #
6
6
  # @example
@@ -26,6 +26,7 @@ require_relative "bfocus/codec"
26
26
  require_relative "bfocus/transport"
27
27
  require_relative "bfocus/resources/base"
28
28
  require_relative "bfocus/resources/customers"
29
+ require_relative "bfocus/resources/people"
29
30
  require_relative "bfocus/resources/products"
30
31
  require_relative "bfocus/resources/release_notes"
31
32
  require_relative "bfocus/resources/knowledge_base"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bfocus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Berni Software
@@ -28,6 +28,7 @@ files:
28
28
  - lib/bfocus/resources/base.rb
29
29
  - lib/bfocus/resources/customers.rb
30
30
  - lib/bfocus/resources/knowledge_base.rb
31
+ - lib/bfocus/resources/people.rb
31
32
  - lib/bfocus/resources/products.rb
32
33
  - lib/bfocus/resources/release_notes.rb
33
34
  - lib/bfocus/transport.rb