bfocus 0.1.0 → 0.2.1

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: 3daefe09e7aa1fed098c71ffdbc8f0b43c020519b72031355aac21a8b16011ed
4
+ data.tar.gz: a21b02db9aff17837cf93cc6063b16324e4593382543fa7c1c0eedd032d916a9
5
5
  SHA512:
6
- metadata.gz: 837084970aaf2a9ee8e4f383c74787021773a0057081ea60f8adf64512813a01abf860029d3333f5d0b624c540bfc3333367c6901f4677b8439f3b1c8d159505
7
- data.tar.gz: f7a941f57940c96fc38c8d7b71dc97a545f4e8edf03d06b63c4b17da7a0c8cd6b8cc13b5220a5d1f4530fa38c2f021e88c41343831492aa11bb7a096a95ba7ab
6
+ metadata.gz: 111b5a2790952c3ba34c11d7d7f8d101b620b3e44b1869730d6663daf97a2f28c27880febb48aca95e3112f41e9159ba5c2174986d7242ad4b242f9ab8ff15c0
7
+ data.tar.gz: 8a8ee123b356b52e8c8fbfeded349d168bdad83c39a010e3328b7635a0424e13f5413295e2c991e4a9a82fe3444c3200cc0fbbe471c82132468a548ac02c0e02
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,257 @@ 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
+ ### Ler os identificadores da pessoa (para reconciliar)
158
+
159
+ `client.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
160
+ cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter sumido
161
+ do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
162
+
163
+ `people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela era
164
+ preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no caminho o id
165
+ principal **ou qualquer um dos extras**.
166
+
167
+ ```ruby
168
+ ids = client.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
169
+ ids["external_id"] # => "app-77" — o principal do cadastro
170
+ ids["identifiers"].each { |i| puts "#{i['external_id']} #{i['label']} #{i['source']}" }
171
+ ```
172
+
173
+ ## Pessoas
174
+
175
+ As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
176
+ é o mesmo `user_external_id` que você assina para o [widget](#identidade-do-widget).
177
+
178
+ ```ruby
179
+ pessoa = client.people.upsert(
180
+ "erp-1042", "app-77",
181
+ name: "Paula Reis", email: "paula@padaria.example", role: "Financeiro", is_primary: true,
182
+ extra_emails: ["paula.reis@pessoal.example"]
183
+ )
184
+ pessoa["status"] # => "created", "updated" ou "unchanged"
185
+
186
+ client.people.list("erp-1042") # todas as pessoas do cliente
187
+ client.people.delete("erp-1042", "app-77") # retira o acesso; a pessoa continua no histórico
188
+ client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
189
+ ```
190
+
191
+ - **Nunca duplica.** O e-mail (ou o telefone) acha a pessoa que já chegou por e-mail, pelo widget
192
+ ou por outro sistema, e ela é **adotada** (ganha o seu `external_id`).
193
+ - A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
194
+ - Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
195
+ - Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
196
+ (contato principal), `extra_emails`, `extra_phones`, `custom_fields`, `clear`.
197
+ - Erros comuns (`code`): `CUSTOMER_NOT_FOUND`, `NAME_REQUIRED` (ao criar), `PERSON_EMAIL_TAKEN`,
198
+ `PERSON_PHONE_TAKEN`, `PERSON_CONTACT_OTHER_CUSTOMER`, `PERSON_EMAIL_STAFF`,
199
+ `PERSON_CLEAR_FIELD_INVALID`, `PERSON_CLEAR_NOT_OWN_RECORD`.
200
+
201
+ ### Campos personalizados da pessoa
202
+
203
+ `custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
204
+ **exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
205
+ ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
206
+ em nada, como em qualquer outro campo.
207
+
208
+ A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
209
+ no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
210
+ querer.
211
+
212
+ Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
213
+
214
+ ```ruby
215
+ pessoa = client.people.upsert(
216
+ "erp-1042", "app-77",
217
+ custom_fields: [ # a lista INTEIRA do seu sistema
218
+ { key: "matricula", label: "Matrícula", value: "4471" },
219
+ { key: "filial", label: "Filial", value: "Centro" }
220
+ ]
221
+ )
222
+ pessoa["custom_fields"].each do |campo|
223
+ puts "#{campo['key']} #{campo['value']} #{campo['visibility']}" # visibility vem do bFocus
224
+ end
225
+ ```
226
+
227
+ ### Apagar o e-mail ou o telefone da pessoa
228
+
229
+ Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o telefone,
230
+ nenhum reenvio o soltava. `clear` apaga.
231
+
232
+ ```ruby
233
+ client.people.upsert("erp-1042", "app-77", clear: ["phone"]) # some o telefone
234
+ client.people.upsert("erp-1042", "app-77", clear: %w[email phone]) # somem os dois
235
+ ```
236
+
237
+ Três regras que parecem contraintuitivas e são de propósito:
238
+
239
+ - **Apagar é explícito.** `phone: nil`, `clear: []` e não passar o argumento continuam significando
240
+ **"não mexe"** — a SDK não traduz `nil` em `clear`. Fazer o `nil` apagar teria apagado, em
241
+ silêncio e na primeira carga seguinte, o dado de todo sistema que manda `nil` para "não tenho
242
+ esse valor".
243
+ - **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer outro
244
+ devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`Bfocus::ValidationError`).
245
+ - **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a API
246
+ recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`Bfocus::ConflictError`): apagar o contato de uma
247
+ ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você tem em
248
+ mãos é o principal ou um extra, use `client.people.identifiers.list(...)`.
249
+
250
+ Vale no `people.upsert` e no `people.batch` (`"clear" => ["phone"]` no item).
251
+
252
+ ### Contato já usado: um 409 que você consegue resolver
253
+
254
+ `PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
255
+ telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `error.data` (a API repete o mesmo
256
+ detalhe em `error.validation`, por compatibilidade):
257
+
258
+ | campo | o que é |
259
+ | --- | --- |
260
+ | `field` | `email` ou `phone` — qual contato está tomado |
261
+ | `owner_external_id` | o identificador da pessoa que já usa esse contato |
262
+ | `owner_name` | o nome dela |
263
+ | `owner_customer_external_id` | o cliente a que ela pertence |
264
+
265
+ **É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
266
+
267
+ - **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
268
+ tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
269
+ essa pessoa.
270
+ - **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
271
+ e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
272
+ casamento por e-mail.
273
+
274
+ ```ruby
275
+ begin
276
+ client.people.upsert("erp-1042", "app-77", name: "Paula Reis", email: "paula@padaria.example")
277
+ rescue Bfocus::ConflictError => e
278
+ raise unless %w[PERSON_EMAIL_TAKEN PERSON_PHONE_TAKEN].include?(e.code)
279
+
280
+ dono = e.data
281
+ if dono["owner_customer_external_id"] == "erp-1042"
282
+ # A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
283
+ client.people.identifiers.add(dono["owner_external_id"], "app-77", label: "ERP")
284
+ else
285
+ # Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
286
+ avisar_cadastro(e.code, dono)
287
+ end
288
+ end
289
+ ```
290
+
291
+ `PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
292
+ definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
293
+ telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
294
+ falha temporária.
295
+
296
+ ## Lotes — `customers.batch` e `people.batch`
297
+
298
+ Até **500 itens por chamada** (`Bfocus::BATCH_MAX`). Acima disso a SDK lança `ArgumentError` antes
299
+ de qualquer requisição — ela não divide sozinha, porque o `index` de cada resultado é a posição no
300
+ lote que **você** enviou. Divida assim:
301
+
302
+ ```ruby
303
+ clientes = meus_clientes.map do |c|
304
+ { external_id: "erp-#{c.id}", name: c.nome, document: c.cnpj, email: c.email }
305
+ end
306
+
307
+ clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
308
+ resultado = client.customers.batch(fatia)
309
+ resultado["results"].each do |item|
310
+ next unless item["status"] == "error"
311
+
312
+ warn "#{fatia[item['index']][:external_id]}: #{item['error']} (HTTP #{item['code']})"
313
+ end
314
+ end
315
+ ```
316
+
317
+ - Cada item de `customers.batch` é `external_id` + os campos do `customers.upsert`.
318
+ - Cada item de `people.batch` é **plano**: `customer_external_id` + `external_id` (da pessoa) + os
319
+ campos do `people.upsert`.
320
+ - Retorno: `results` (um por item: `index`, `status` — `created`/`updated`/`unchanged`/`error` —,
321
+ `external_id`, `merged_into`, `error` com o código estável e `code` com o status HTTP do item) +
322
+ `summary` (`created`, `updated`, `unchanged`, `error`).
323
+ - **Um erro não desfaz os outros**: confira `summary["error"]` e registre os itens com erro.
324
+ - Lista vazia devolve o resultado zerado sem fazer requisição.
325
+ - `idempotency_key:` vale para o lote inteiro (um lote = uma chamada).
326
+
327
+ ## Sincronizar clientes e usuários do seu sistema
328
+
329
+ **Ids com o prefixo do sistema, sem `:`.** Use `-` como separador — `erp-1042` para clientes,
330
+ `app-77` para pessoas — ou UUIDs puros: vários sistemas seus convivem no mesmo bFocus sem colisão. A
331
+ assinatura do widget recusa `:` (é o separador dela), então não use `:` em nenhum `external_id` de
332
+ cliente ou pessoa.
333
+
334
+ **Carga inicial (no deploy):** clientes em fatias de 500 → vincule cada um ao produto → pessoas em
335
+ fatias de 500.
336
+
337
+ ```ruby
338
+ def carga_inicial(client, clientes, usuarios)
339
+ clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
340
+ r = client.customers.batch(fatia.map { |c| { external_id: "erp-#{c.id}", name: c.nome } })
341
+ registrar_erros(r, fatia) if r["summary"]["error"].positive?
342
+ end
343
+
344
+ clientes.each { |c| client.customers.products.attach("erp-#{c.id}", "erp-cloud") }
345
+
346
+ usuarios.each_slice(Bfocus::BATCH_MAX) do |fatia|
347
+ itens = fatia.map do |u|
348
+ { customer_external_id: "erp-#{u.cliente_id}", external_id: "app-#{u.id}",
349
+ name: u.nome, email: u.email }
350
+ end
351
+ r = client.people.batch(itens)
352
+ registrar_erros(r, fatia) if r["summary"]["error"].positive?
353
+ end
354
+ end
355
+ ```
356
+
357
+ **Depois, no dia a dia**, espelhe cada evento do seu sistema:
358
+
359
+ | No seu sistema | No bFocus |
360
+ | --- | --- |
361
+ | criou/alterou cliente | `customers.upsert` |
362
+ | criou/alterou usuário | `people.upsert` |
363
+ | excluiu/desativou usuário | `people.delete` |
364
+ | excluiu cliente | `customers.delete` |
365
+
366
+ Vincule o cliente ao produto com `customers.products.attach`. Se a resposta trouxer `merged_into`,
367
+ o cadastro foi unificado em outro: atualize o id do seu lado.
368
+
369
+ **Nunca bloqueie a requisição do seu usuário esperando o bFocus.** Enfileire (job/outbox) e tente de
370
+ novo com backoff; a SDK já repete 429/5xx com a mesma `Idempotency-Key`, e a fila cobre
371
+ indisponibilidades longas.
372
+
373
+ ```ruby
374
+ # app/jobs/bfocus_sync_usuario_job.rb (ActiveJob; o mesmo vale para Sidekiq etc.)
375
+ class BfocusSyncUsuarioJob < ApplicationJob
376
+ retry_on Bfocus::NetworkError, Bfocus::RateLimitError, Bfocus::ServerError,
377
+ wait: :polynomially_longer, attempts: 10
378
+
379
+ def perform(usuario_id)
380
+ u = Usuario.find(usuario_id)
381
+ if u.ativo?
382
+ BFOCUS.people.upsert("erp-#{u.cliente_id}", "app-#{u.id}", name: u.nome, email: u.email)
383
+ else
384
+ BFOCUS.people.delete("erp-#{u.cliente_id}", "app-#{u.id}")
385
+ end
386
+ end
387
+ end
388
+
389
+ # no model, depois de salvar — a requisição do usuário não espera o bFocus:
390
+ after_commit { BfocusSyncUsuarioJob.perform_later(id) }
391
+ ```
392
+
142
393
  ## Produtos
143
394
 
144
395
  ```ruby
@@ -273,6 +524,11 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
273
524
  | Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
274
525
  | Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
275
526
  | Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
527
+ | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id`, `custom_fields` (lista de `{key, label, value, visibility}`) |
528
+ | Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
529
+ | Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
530
+ | Identificadores da pessoa (`people.identifiers.list`, `add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
531
+ | 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
532
  | Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
277
533
  | 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
534
  | Artigo — resumo (`kb.articles.list`, `list_all`) | `id`, `external_id`, `product`, `title`, `excerpt`, `status`, `origin`, `published_at`, `created_at`, `updated_at` |
@@ -298,7 +554,10 @@ Qualquer resposta fora de 2xx levanta `Bfocus::Error` (ou uma subclasse):
298
554
  | `Bfocus::ServerError` | 5xx |
299
555
  | `Bfocus::NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
300
556
 
301
- Todas têm `code`, `status`, `request_id`, `validation`, `retry_after`, `required_scope` e `body`.
557
+ Todas têm `code`, `status`, `request_id`, `validation`, `data`, `retry_after`, `required_scope` e
558
+ `body`. O `data` é o `data` do corpo: o detalhe estruturado que alguns erros trazem (`{}` quando
559
+ não há) — é por ele que um 409 de contato tomado diz de **quem** é o contato (veja
560
+ [Pessoas](#pessoas)).
302
561
  **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
303
562
  `MODULE_NOT_CONTRACTED`, `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao
304
563
  falar com o suporte, informe o `request_id`: ele vem do corpo da resposta, senão do header
@@ -374,6 +633,28 @@ assinatura = Bfocus.sign_widget_identity(
374
633
  # que abre o widget.
375
634
  ```
376
635
 
636
+ ### Identidade do widget v2 (com validade)
637
+
638
+ A v2 carimba o instante na assinatura, então uma assinatura vazada deixa de valer sozinha:
639
+
640
+ ```ruby
641
+ user_hash = Bfocus.sign_widget_identity_v2(
642
+ ENV.fetch("BFOCUS_WIDGET_SECRET"),
643
+ "app-77", # user_external_id: sem ":" (é o separador; a API recusa)
644
+ "erp-1042" # customer_external_id: a empresa dele
645
+ )
646
+ # => "v2.<ts>.<hex>", ex.: "v2.1789000000.9c1e…"
647
+
648
+ # instante explícito (segundos unix, não ms; ou Time) — útil em testes:
649
+ Bfocus.sign_widget_identity_v2(segredo, "app-77", "erp-1042", now: 1_789_000_000)
650
+ ```
651
+
652
+ - `hex` = HMAC-SHA256 em hex minúsculo de `"v2:<ts>:<user_external_id>:<customer_external_id>"`.
653
+ - Vale de **7 dias atrás até 5 minutos à frente**: gere a cada renderização da página, **nunca
654
+ guarde**. Vai no mesmo lugar da v1 (`userHash` do widget).
655
+ - O id do usuário não pode ter `:` (`ArgumentError`) — use `-` como separador (`app-77`).
656
+ - A v1 continua aceita.
657
+
377
658
  ## Versões
378
659
 
379
660
  **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)
data/lib/bfocus/errors.rb CHANGED
@@ -21,6 +21,12 @@ module Bfocus
21
21
  attr_reader :request_id
22
22
  # @return [Hash{String=>String}] motivos por campo (erros de validação); `{}` quando não há.
23
23
  attr_reader :validation
24
+ # @return [Hash] o `data` do corpo do erro: o detalhe estruturado que alguns erros trazem
25
+ # (`{}` quando não há). É onde vem, por exemplo, de quem é o contato já usado num 409
26
+ # `PERSON_EMAIL_TAKEN`/`PERSON_PHONE_TAKEN` (`field`, `owner_external_id`, `owner_name`,
27
+ # `owner_customer_external_id`) e o `owner` de um `IDENTIFIER_IN_USE`. A API repete esse
28
+ # detalhe em {#validation}, por compatibilidade com as SDKs que ainda não expunham `data`.
29
+ attr_reader :data
24
30
  # @return [Integer, Float, nil] segundos do header `Retry-After` (só em 429).
25
31
  attr_reader :retry_after
26
32
  # @return [String, nil] escopo que faltou na chave (header `X-Required-Scope`, só em 403).
@@ -29,11 +35,12 @@ module Bfocus
29
35
  attr_reader :body
30
36
 
31
37
  def initialize(message = nil, code: nil, status: 0, request_id: nil, validation: nil,
32
- retry_after: nil, required_scope: nil, body: nil)
38
+ retry_after: nil, required_scope: nil, body: nil, data: nil)
33
39
  @code = code
34
40
  @status = status
35
41
  @request_id = request_id
36
42
  @validation = validation.is_a?(Hash) ? validation.dup : {}
43
+ @data = data.is_a?(Hash) ? data.dup : {}
37
44
  @retry_after = retry_after
38
45
  @required_scope = required_scope
39
46
  @body = body
@@ -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,152 @@
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
+ # Todos os identificadores da pessoa: o principal (`"external_id"` do retorno) e os extras.
12
+ # `GET /people/{person_external_id}/identifiers` (escopo `customers:read`). Aceita no
13
+ # caminho o principal OU qualquer um dos extras.
14
+ #
15
+ # É a fonte de verdade para RECONCILIAR: {People#list} mostra só o identificador principal,
16
+ # então um id que virou extra some de lá sem ter sumido do cadastro — e, sem esta leitura,
17
+ # era preciso ESCREVER (tentar um {#add}) para descobrir o que tinha acontecido.
18
+ #
19
+ # @return [Hash]
20
+ # @raise [Bfocus::NotFoundError] `PERSON_NOT_FOUND`.
21
+ def list(person_external_id, timeout: nil)
22
+ call("GET", "/people/#{segment(person_external_id, 'person_external_id')}/identifiers",
23
+ timeout: timeout)
24
+ end
25
+
26
+ # Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
27
+ #
28
+ # @param label [String, nil] rótulo livre. Não informado = sem corpo.
29
+ # @return [Hash]
30
+ def add(person_external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
31
+ pid = segment(person_external_id, "person_external_id")
32
+ extra = segment(extra_id, "extra_id")
33
+ body = label.equal?(UNSET) ? nil : { "label" => label }
34
+ call("PUT", "/people/#{pid}/identifiers/#{extra}",
35
+ body: body, idempotency_key: idempotency_key, timeout: timeout)
36
+ end
37
+
38
+ # Desliga `extra_id` da pessoa. `DELETE /people/{person_external_id}/identifiers/{extra_id}`
39
+ # @return [Hash]
40
+ def remove(person_external_id, extra_id, idempotency_key: nil, timeout: nil)
41
+ pid = segment(person_external_id, "person_external_id")
42
+ extra = segment(extra_id, "extra_id")
43
+ call("DELETE", "/people/#{pid}/identifiers/#{extra}",
44
+ idempotency_key: idempotency_key, timeout: timeout)
45
+ end
46
+ end
47
+
48
+ # Pessoas (usuários do seu sistema) de um cliente — `client.people`. Sub-recurso:
49
+ # {#identifiers}.
50
+ #
51
+ # Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
52
+ # `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
53
+ # `"customer_external_id"` e `"custom_fields"` (lista de `{"key", "label", "value",
54
+ # "visibility"}`). O `upsert` devolve também `"status"`
55
+ # (`"created"`/`"updated"`/`"unchanged"`).
56
+ #
57
+ # O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
58
+ # pode ter `:` (use outro separador, ex.: `"app-77"`).
59
+ class People < Base
60
+ # @return [PersonIdentifiers]
61
+ attr_reader :identifiers
62
+
63
+ def initialize(transport)
64
+ super
65
+ @identifiers = PersonIdentifiers.new(transport)
66
+ end
67
+
68
+ # Cria ou atualiza uma pessoa do cliente pelo `external_id` dela.
69
+ # `PUT /customers/{customer_external_id}/people/{person_external_id}`
70
+ #
71
+ # Só o que vier muda; `nil` limpa. O e-mail (ou telefone) acha a pessoa que já chegou por
72
+ # outro caminho e ela é adotada, nunca duplicada; se ela estava em outro cliente, é
73
+ # transferida. `access: true` devolve o acesso retirado por {#delete}.
74
+ #
75
+ # @param access [Boolean] pode abrir chamados/usar o widget.
76
+ # @param is_primary [Boolean] contato principal do cliente.
77
+ # @param extra_emails [Array<String>] e-mails adicionais.
78
+ # @param extra_phones [Array<String>] telefones adicionais.
79
+ # @param custom_fields [Array<Hash>] campos personalizados (`{"key", "label", "value"}`).
80
+ # Ao contrário de `extra_emails`/`extra_phones`, a lista SUBSTITUI a lista inteira: mande
81
+ # o que o seu sistema tem hoje, porque campo que ficar de fora é REMOVIDO. Não passar o
82
+ # argumento não mexe em nada. A visibilidade é decidida no bFocus e preservada entre
83
+ # sincronizações.
84
+ # @param clear [Array<String>] campos a APAGAR nesta pessoa: `["email"]`, `["phone"]` ou os
85
+ # dois. Apagar é EXPLÍCITO: `phone: nil`, `clear: []` e não passar o argumento continuam
86
+ # significando "não mexe" — a SDK não traduz `nil` em `clear`. Campo fora da lista aceita
87
+ # é RECUSADO pela API (422 `PERSON_CLEAR_FIELD_INVALID`), não ignorado; e só se limpa a
88
+ # PRÓPRIA ficha: alcançando a pessoa por um identificador EXTRA, a API recusa (409
89
+ # `PERSON_CLEAR_NOT_OWN_RECORD`) — apagar contato de ficha alcançada por apelido seria
90
+ # apagar dado de outro sistema.
91
+ # @return [Hash] a pessoa + `"status"`.
92
+ def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
93
+ role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
94
+ custom_fields: UNSET, clear: UNSET, idempotency_key: nil, timeout: nil)
95
+ cid = segment(customer_external_id, "customer_external_id")
96
+ pid = segment(person_external_id, "person_external_id")
97
+ person = compact(
98
+ "name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
99
+ "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones,
100
+ "custom_fields" => custom_fields, "clear" => clear
101
+ )
102
+ call("PUT", "/customers/#{cid}/people/#{pid}",
103
+ body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
104
+ end
105
+
106
+ # Pessoas do cliente. `GET /customers/{customer_external_id}/people`
107
+ # @return [Array<Hash>]
108
+ def list(customer_external_id, timeout: nil)
109
+ call("GET", "/customers/#{segment(customer_external_id, 'customer_external_id')}/people",
110
+ timeout: timeout)
111
+ end
112
+
113
+ # Retira o acesso da pessoa (ela continua no histórico).
114
+ # `DELETE /customers/{customer_external_id}/people/{person_external_id}`
115
+ # @return [Hash] a pessoa, com `"access" => false`.
116
+ def delete(customer_external_id, person_external_id, idempotency_key: nil, timeout: nil)
117
+ cid = segment(customer_external_id, "customer_external_id")
118
+ pid = segment(person_external_id, "person_external_id")
119
+ call("DELETE", "/customers/#{cid}/people/#{pid}",
120
+ idempotency_key: idempotency_key, timeout: timeout)
121
+ end
122
+
123
+ # Cria/atualiza até {Bfocus::BATCH_MAX} (500) pessoas numa chamada. `POST /people/batch`
124
+ #
125
+ # Cada item (Hash plano, chaves string ou símbolo): `customer_external_id` e `external_id`
126
+ # (da pessoa), ambos obrigatórios, + os campos do {#upsert} (ausente = não muda; `nil`
127
+ # limpa). No fio vira `{"customer_external_id" => …, "person" => {"external_id" => …, …}}`.
128
+ #
129
+ # A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
130
+ # requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
131
+ # a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
132
+ # resultado zerado sem chamar a API.
133
+ #
134
+ # @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
135
+ # "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}`
136
+ # @raise [ArgumentError] mais de 500 itens ou item sem `customer_external_id`/`external_id`.
137
+ # @raise [TypeError] `items` não é lista de Hash.
138
+ def batch(items, idempotency_key: nil, timeout: nil)
139
+ list = batch_items(items, "people.batch")
140
+ return empty_batch_result if list.empty?
141
+
142
+ wire = list.each_with_index.map do |item, index|
143
+ customer = batch_id!(item, "customer_external_id", "people.batch", index)
144
+ batch_id!(item, "external_id", "people.batch", index)
145
+ { "customer_external_id" => customer, "person" => item.reject { |key, _| key == "customer_external_id" } }
146
+ end
147
+ call("POST", "/people/batch",
148
+ body: { "items" => wire }, idempotency_key: idempotency_key, timeout: timeout)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -211,6 +211,7 @@ module Bfocus
211
211
  human = nil
212
212
  request_id = nil
213
213
  validation = {}
214
+ data = {}
214
215
 
215
216
  if payload.is_a?(Hash)
216
217
  err = payload["error"]
@@ -222,6 +223,10 @@ module Bfocus
222
223
  end
223
224
  human = msg if msg.is_a?(String) && !msg.empty? && msg != code
224
225
  validation = payload["validation"] if payload["validation"].is_a?(Hash)
226
+ # `data` é o detalhe estruturado do erro (de quem é o contato já usado, o dono de um
227
+ # identificador…). A API também o repete em `validation`, mas quem lê o erro precisa
228
+ # alcançá-lo sem depender dessa duplicação.
229
+ data = payload["data"] if payload["data"].is_a?(Hash)
225
230
  rid = payload["request_id"]
226
231
  request_id = rid if rid.is_a?(String) && !rid.empty?
227
232
  elsif !text.strip.empty?
@@ -247,6 +252,7 @@ module Bfocus
247
252
  status: status,
248
253
  request_id: request_id,
249
254
  validation: validation,
255
+ data: data,
250
256
  retry_after: retry_after,
251
257
  required_scope: required_scope,
252
258
  body: payload.nil? ? present(text) : payload
@@ -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.1"
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,14 +1,14 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Berni Software
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-09-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Clientes, produtos, release notes, base de conhecimento e agentes de
14
14
  IA do bFocus. Zero dependências de runtime, novas tentativas e idempotência automáticas.
@@ -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