assinafy 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +168 -0
- data/LICENSE +19 -0
- data/README.md +463 -0
- data/lib/assinafy/client.rb +245 -0
- data/lib/assinafy/configuration.rb +157 -0
- data/lib/assinafy/errors.rb +83 -0
- data/lib/assinafy/null_logger.rb +9 -0
- data/lib/assinafy/resources/account_resource.rb +267 -0
- data/lib/assinafy/resources/assignment_resource.rb +476 -0
- data/lib/assinafy/resources/auth_resource.rb +251 -0
- data/lib/assinafy/resources/base_resource.rb +250 -0
- data/lib/assinafy/resources/document_resource.rb +912 -0
- data/lib/assinafy/resources/field_resource.rb +274 -0
- data/lib/assinafy/resources/signer_document_resource.rb +222 -0
- data/lib/assinafy/resources/signer_resource.rb +422 -0
- data/lib/assinafy/resources/tag_resource.rb +183 -0
- data/lib/assinafy/resources/template_resource.rb +215 -0
- data/lib/assinafy/resources/user_resource.rb +182 -0
- data/lib/assinafy/resources/webhook_resource.rb +222 -0
- data/lib/assinafy/support/webhook_verifier.rb +129 -0
- data/lib/assinafy/utils.rb +122 -0
- data/lib/assinafy/version.rb +5 -0
- data/lib/assinafy.rb +25 -0
- metadata +195 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Assinafy
|
|
4
|
+
module Resources
|
|
5
|
+
# Signer management. Covers both:
|
|
6
|
+
#
|
|
7
|
+
# - Account-scoped CRUD on signers (authenticated as a workspace user).
|
|
8
|
+
# - Signer self-service endpoints (authenticated via `signer-access-code`).
|
|
9
|
+
#
|
|
10
|
+
# See https://api.assinafy.com.br/v1/docs#signer for the full
|
|
11
|
+
# documentation of these endpoints.
|
|
12
|
+
class SignerResource < BaseResource
|
|
13
|
+
EMAIL_REGEX = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
|
|
14
|
+
SIGNATURE_TYPES = %w[signature initial].freeze
|
|
15
|
+
|
|
16
|
+
# Create a signer in the workspace.
|
|
17
|
+
#
|
|
18
|
+
# @param payload [Hash]
|
|
19
|
+
# @option payload [String] :full_name required
|
|
20
|
+
# @option payload [String] :email optional, validated when present
|
|
21
|
+
# @option payload [String] :whatsapp_phone_number optional
|
|
22
|
+
# @option payload [String] :phone alias for :whatsapp_phone_number
|
|
23
|
+
# @param account_id_override [String, nil]
|
|
24
|
+
# @return [Hash] signer object (envelope `data` unwrapped)
|
|
25
|
+
# @see POST /accounts/{account_id}/signers
|
|
26
|
+
# @example Create a signer
|
|
27
|
+
# signer = client.signers.create(full_name: 'Example Signer', email: 'signer@example.com')
|
|
28
|
+
#
|
|
29
|
+
# # Request body the SDK sends (nil/omitted optional fields are stripped):
|
|
30
|
+
# # {
|
|
31
|
+
# # "full_name": "Example Signer",
|
|
32
|
+
# # "email": "signer@example.com"
|
|
33
|
+
# # }
|
|
34
|
+
#
|
|
35
|
+
# # => {
|
|
36
|
+
# # "resource" => "signer",
|
|
37
|
+
# # "id" => "signer-id",
|
|
38
|
+
# # "full_name" => "Example Signer",
|
|
39
|
+
# # "email" => "signer@example.com",
|
|
40
|
+
# # "whatsapp_phone_number" => nil,
|
|
41
|
+
# # "has_accepted_terms" => false
|
|
42
|
+
# # }
|
|
43
|
+
def create(payload, account_id_override = nil)
|
|
44
|
+
body = signer_payload(payload, require_full_name: true)
|
|
45
|
+
acc_id = account_id(account_id_override)
|
|
46
|
+
|
|
47
|
+
@logger.info('Creating signer')
|
|
48
|
+
|
|
49
|
+
call('Failed to create signer') do
|
|
50
|
+
http_post("accounts/#{acc_id}/signers", body)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fetch a signer by ID.
|
|
55
|
+
#
|
|
56
|
+
# @param signer_id [String]
|
|
57
|
+
# @param account_id_override [String, nil]
|
|
58
|
+
# @return [Hash] signer object (envelope `data` unwrapped)
|
|
59
|
+
# @see GET /accounts/{account_id}/signers/{signer_id}
|
|
60
|
+
# @example Fetch a signer by ID
|
|
61
|
+
# signer = client.signers.get('signer-id')
|
|
62
|
+
#
|
|
63
|
+
# # => {
|
|
64
|
+
# # "resource" => "signer",
|
|
65
|
+
# # "id" => "signer-id",
|
|
66
|
+
# # "full_name" => "Example Signer",
|
|
67
|
+
# # "email" => "signer@example.com",
|
|
68
|
+
# # "whatsapp_phone_number" => nil,
|
|
69
|
+
# # "has_accepted_terms" => false
|
|
70
|
+
# # }
|
|
71
|
+
def get(signer_id, account_id_override = nil)
|
|
72
|
+
acc_id = account_id(account_id_override)
|
|
73
|
+
sid = require_id(signer_id, 'Signer ID')
|
|
74
|
+
|
|
75
|
+
call('Failed to fetch signer') do
|
|
76
|
+
http_get("accounts/#{acc_id}/signers/#{sid}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# List signers in the workspace, with pagination metadata.
|
|
81
|
+
#
|
|
82
|
+
# @param params [Hash] query parameters (`search`, `page`, `per_page`)
|
|
83
|
+
# @param account_id_override [String, nil]
|
|
84
|
+
# @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: { ... } }`
|
|
85
|
+
# @see GET /accounts/{account_id}/signers
|
|
86
|
+
# @example List signers, page 1, 3 per page
|
|
87
|
+
# result = client.signers.list(page: 1, per_page: 3)
|
|
88
|
+
#
|
|
89
|
+
# # => {
|
|
90
|
+
# # data: [
|
|
91
|
+
# # {
|
|
92
|
+
# # "id" => "signer-id",
|
|
93
|
+
# # "full_name" => "Example Signer",
|
|
94
|
+
# # "email" => "signer@example.com",
|
|
95
|
+
# # "whatsapp_phone_number" => nil,
|
|
96
|
+
# # "has_accepted_terms" => false
|
|
97
|
+
# # }
|
|
98
|
+
# # # ... (one Hash per signer)
|
|
99
|
+
# # ],
|
|
100
|
+
# # meta: { current_page: 1, per_page: 3, total: 4, last_page: 2 }
|
|
101
|
+
# # }
|
|
102
|
+
def list(params = {}, account_id_override = nil)
|
|
103
|
+
acc_id = account_id(account_id_override)
|
|
104
|
+
|
|
105
|
+
call_list('Failed to list signers') do
|
|
106
|
+
http_get("accounts/#{acc_id}/signers", params)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Partially update a signer. Omitted fields are left unchanged. Updating
|
|
111
|
+
# `email` or `whatsapp_phone_number` is rejected while that channel is
|
|
112
|
+
# verified on an in-flight document; changing an unverified channel rotates
|
|
113
|
+
# its access and verification codes.
|
|
114
|
+
#
|
|
115
|
+
# @param signer_id [String]
|
|
116
|
+
# @param payload [Hash]
|
|
117
|
+
# @option payload [String] :full_name
|
|
118
|
+
# @option payload [String] :email
|
|
119
|
+
# @option payload [String] :whatsapp_phone_number E.164; normalized on save
|
|
120
|
+
# @option payload [String] :phone alias for `:whatsapp_phone_number`
|
|
121
|
+
# @option payload [String] :government_id CPF/CNPJ; digits only on save
|
|
122
|
+
# @param account_id_override [String, nil]
|
|
123
|
+
# @return [Hash] updated signer object (envelope `data` unwrapped)
|
|
124
|
+
# @see PUT /accounts/{account_id}/signers/{signer_id}
|
|
125
|
+
# @example Update a signer's full name and government ID
|
|
126
|
+
# signer = client.signers.update('signer-id', full_name: 'Updated Signer',
|
|
127
|
+
# government_id: '00000000000')
|
|
128
|
+
#
|
|
129
|
+
# # Request body the SDK sends (omitted fields are not nulled):
|
|
130
|
+
# # { "full_name": "Updated Signer", "government_id": "00000000000" }
|
|
131
|
+
#
|
|
132
|
+
# # => {
|
|
133
|
+
# # "resource" => "signer",
|
|
134
|
+
# # "id" => "signer-id",
|
|
135
|
+
# # "full_name" => "Updated Signer",
|
|
136
|
+
# # "email" => "signer@example.com",
|
|
137
|
+
# # "whatsapp_phone_number" => nil,
|
|
138
|
+
# # "has_accepted_terms" => false
|
|
139
|
+
# # }
|
|
140
|
+
def update(signer_id, payload, account_id_override = nil)
|
|
141
|
+
acc_id = account_id(account_id_override)
|
|
142
|
+
sid = require_id(signer_id, 'Signer ID')
|
|
143
|
+
body = signer_payload(payload, require_full_name: false, include_government_id: true)
|
|
144
|
+
|
|
145
|
+
call('Failed to update signer') do
|
|
146
|
+
http_put("accounts/#{acc_id}/signers/#{sid}", body)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Delete a signer.
|
|
151
|
+
#
|
|
152
|
+
# @param signer_id [String]
|
|
153
|
+
# @param account_id_override [String, nil]
|
|
154
|
+
# @return [nil] the SDK returns nil on success (response body is discarded)
|
|
155
|
+
# @see DELETE /accounts/{account_id}/signers/{signer_id}
|
|
156
|
+
# @example Delete a signer
|
|
157
|
+
# client.signers.delete('signer-id')
|
|
158
|
+
# # => nil
|
|
159
|
+
def delete(signer_id, account_id_override = nil)
|
|
160
|
+
acc_id = account_id(account_id_override)
|
|
161
|
+
sid = require_id(signer_id, 'Signer ID')
|
|
162
|
+
|
|
163
|
+
call_void('Failed to delete signer') do
|
|
164
|
+
http_delete("accounts/#{acc_id}/signers/#{sid}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Convenience: find a signer by email using the documented `search` query
|
|
169
|
+
# parameter, then do a case-insensitive client-side match. Walks every
|
|
170
|
+
# result page (using a fixed page size; the API clamps `per-page` to its
|
|
171
|
+
# own maximum) until a match is found or the pages are exhausted. Returns
|
|
172
|
+
# `nil` when no match is found (including on 404).
|
|
173
|
+
#
|
|
174
|
+
# @param email [String]
|
|
175
|
+
# @param account_id_override [String, nil]
|
|
176
|
+
# @return [Hash, nil] the matching signer object, or nil when none matches
|
|
177
|
+
# @example Find a signer by email
|
|
178
|
+
# signer = client.signers.find_by_email('signer@example.com')
|
|
179
|
+
#
|
|
180
|
+
# # Internally pages through GET /accounts/{account_id}/signers?search=...&per-page=50
|
|
181
|
+
# # and returns the single matching signer Hash (case-insensitive on email):
|
|
182
|
+
# # => {
|
|
183
|
+
# # "id" => "signer-id",
|
|
184
|
+
# # "full_name" => "Example Signer",
|
|
185
|
+
# # "email" => "signer@example.com",
|
|
186
|
+
# # "whatsapp_phone_number" => nil,
|
|
187
|
+
# # "has_accepted_terms" => false
|
|
188
|
+
# # }
|
|
189
|
+
# #
|
|
190
|
+
# # => nil # when no signer matches (including on a 404)
|
|
191
|
+
def find_by_email(email, account_id_override = nil)
|
|
192
|
+
assert_email!(email.to_s)
|
|
193
|
+
target = email.to_s.downcase
|
|
194
|
+
page = 1
|
|
195
|
+
|
|
196
|
+
loop do
|
|
197
|
+
result = list({ search: email, page: page, per_page: 50 }, account_id_override)
|
|
198
|
+
match = result[:data].find { |signer| signer['email'].to_s.downcase == target }
|
|
199
|
+
return match if match
|
|
200
|
+
|
|
201
|
+
meta = result[:meta]
|
|
202
|
+
break unless meta && meta[:last_page] && page < meta[:last_page]
|
|
203
|
+
|
|
204
|
+
page += 1
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
nil
|
|
208
|
+
rescue ApiError => e
|
|
209
|
+
raise unless e.status_code == 404
|
|
210
|
+
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Fetch the authenticated signer's own profile (signer-access-code auth).
|
|
215
|
+
#
|
|
216
|
+
# @param signer_access_code [String]
|
|
217
|
+
# @return [Hash] signer object plus self-only fields (envelope `data` unwrapped)
|
|
218
|
+
# @see GET /signers/self
|
|
219
|
+
# @example Fetch the signer's own profile
|
|
220
|
+
# me = client.signers.self_data(signer_access_code: 'signer-access-code')
|
|
221
|
+
#
|
|
222
|
+
# # => {
|
|
223
|
+
# # "resource" => "signer",
|
|
224
|
+
# # "id" => "signer-id",
|
|
225
|
+
# # "full_name" => "Signer Name",
|
|
226
|
+
# # "email" => "signer@example.com",
|
|
227
|
+
# # "whatsapp_phone_number" => "+15555550100",
|
|
228
|
+
# # "has_accepted_terms" => false,
|
|
229
|
+
# # "has_signature" => false, # self-only field
|
|
230
|
+
# # "has_initial" => false, # self-only field
|
|
231
|
+
# # "is_signature_reusable" => false # self-only field
|
|
232
|
+
# # }
|
|
233
|
+
def self_data(signer_access_code:)
|
|
234
|
+
call('Failed to fetch signer self') do
|
|
235
|
+
http_get('signers/self', signer_access_code: signer_access_code)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Accept the platform's terms of use as the signer.
|
|
240
|
+
#
|
|
241
|
+
# The `signer-access-code` is sent as the documented query parameter (the
|
|
242
|
+
# `signerAccessCode` security scheme is `in: query`), consistent with every
|
|
243
|
+
# other signer-authenticated endpoint. This operation has no request body.
|
|
244
|
+
#
|
|
245
|
+
# @param signer_access_code [String]
|
|
246
|
+
# @return [nil] the documented success envelope has no `data` payload
|
|
247
|
+
# @see PUT /signers/accept-terms
|
|
248
|
+
# @example Accept the terms of use
|
|
249
|
+
# result = client.signers.accept_terms(signer_access_code: 'signer-access-code')
|
|
250
|
+
#
|
|
251
|
+
# # Request: PUT /signers/accept-terms?signer-access-code=signer-access-code
|
|
252
|
+
# # Body: none
|
|
253
|
+
#
|
|
254
|
+
# # Response: { "status": 200, "message": "Terms accepted" }
|
|
255
|
+
# # => nil
|
|
256
|
+
def accept_terms(signer_access_code:)
|
|
257
|
+
call('Failed to accept signer terms') do
|
|
258
|
+
http_put('signers/accept-terms', nil, signer_access_code: signer_access_code)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Verify the signer's email with a one-time verification code.
|
|
263
|
+
#
|
|
264
|
+
# @param verification_code [String]
|
|
265
|
+
# @param signer_access_code [String]
|
|
266
|
+
# @return [nil] the documented success envelope has no `data` payload
|
|
267
|
+
# @see POST /verify
|
|
268
|
+
# @example Verify the signer's email with a one-time code
|
|
269
|
+
# result = client.signers.verify_email(
|
|
270
|
+
# verification_code: '123456',
|
|
271
|
+
# signer_access_code: 'signer-access-code'
|
|
272
|
+
# )
|
|
273
|
+
#
|
|
274
|
+
# # Request: POST /verify?signer-access-code=signer-access-code
|
|
275
|
+
# # Body: { "verification-code": "123456" }
|
|
276
|
+
#
|
|
277
|
+
# # Response: { "status": 200, "message": "Code verified successfully" }
|
|
278
|
+
# # => nil
|
|
279
|
+
def verify_email(verification_code:, signer_access_code:)
|
|
280
|
+
call('Failed to verify signer email') do
|
|
281
|
+
http_post(
|
|
282
|
+
'verify',
|
|
283
|
+
body_params(verification_code: verification_code),
|
|
284
|
+
signer_access_code: signer_access_code
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Confirm signer data before signing a virtual assignment. The documented
|
|
290
|
+
# body fields are `full_name`, `email`, and `government_id`; the payload is
|
|
291
|
+
# passed through unchanged, so any additional fields the API accepts can be
|
|
292
|
+
# supplied as well.
|
|
293
|
+
#
|
|
294
|
+
# @param document_id [String]
|
|
295
|
+
# @param payload [Hash] `:full_name`, `:email`, `:government_id`
|
|
296
|
+
# @param signer_access_code [String]
|
|
297
|
+
# @return [Hash] the updated signer object (envelope `data` unwrapped)
|
|
298
|
+
# @see PUT /documents/{documentId}/signers/confirm-data
|
|
299
|
+
# @example Confirm the signer's data
|
|
300
|
+
# result = client.signers.confirm_data(
|
|
301
|
+
# 'document-id',
|
|
302
|
+
# { full_name: 'Signer Name', email: 'signer@example.com', government_id: '00000000000' },
|
|
303
|
+
# signer_access_code: 'signer-access-code'
|
|
304
|
+
# )
|
|
305
|
+
#
|
|
306
|
+
# # signer-access-code is sent as a query param; the JSON body the SDK sends:
|
|
307
|
+
# # { "full_name": "Signer Name", "email": "signer@example.com", "government_id": "00000000000" }
|
|
308
|
+
#
|
|
309
|
+
# # => {
|
|
310
|
+
# # "resource" => "signer", "id" => "signer-id", "full_name" => "Signer Name",
|
|
311
|
+
# # "email" => "signer@example.com", "whatsapp_phone_number" => nil, "has_accepted_terms" => false
|
|
312
|
+
# # }
|
|
313
|
+
def confirm_data(document_id, payload, signer_access_code:)
|
|
314
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
315
|
+
body = body_params(require_payload(payload))
|
|
316
|
+
|
|
317
|
+
call('Failed to confirm signer data') do
|
|
318
|
+
http_put("documents/#{doc_id}/signers/confirm-data", body,
|
|
319
|
+
signer_access_code: signer_access_code)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Upload the signer's signature image. The request body is raw image bytes.
|
|
324
|
+
#
|
|
325
|
+
# @param content [String] raw image bytes
|
|
326
|
+
# @param signer_access_code [String]
|
|
327
|
+
# @param type [String] `signature` or `initial`
|
|
328
|
+
# @param content_type [String] e.g. `image/png`
|
|
329
|
+
# @param reuse [Boolean, nil] when true, marks the signature as
|
|
330
|
+
# reusable for future documents (documented `reuse` query flag)
|
|
331
|
+
# @return [nil, Array] `nil` for the OpenAPI's no-data envelope; some deployed
|
|
332
|
+
# versions return `data: []`, which the SDK passes through as an empty Array
|
|
333
|
+
# @see POST /signature
|
|
334
|
+
# @example Upload a PNG signature image
|
|
335
|
+
# bytes = File.binread('signature.png')
|
|
336
|
+
# client.signers.upload_signature(
|
|
337
|
+
# bytes,
|
|
338
|
+
# signer_access_code: 'signer-access-code',
|
|
339
|
+
# type: 'signature',
|
|
340
|
+
# content_type: 'image/png'
|
|
341
|
+
# )
|
|
342
|
+
#
|
|
343
|
+
# # The SDK sends the RAW image bytes as the body, with
|
|
344
|
+
# # Content-Type: image/png and ?signer-access-code=...&type=signature query params.
|
|
345
|
+
#
|
|
346
|
+
# # => nil # documented no-data envelope
|
|
347
|
+
# # => [] # when a deployed API version returns data: []
|
|
348
|
+
def upload_signature(content, signer_access_code:, type: 'signature', content_type: 'image/png', reuse: nil)
|
|
349
|
+
sig_type = signature_type(type)
|
|
350
|
+
|
|
351
|
+
call('Failed to upload signer signature') do
|
|
352
|
+
@connection.post('signature') do |request|
|
|
353
|
+
request.params.update(
|
|
354
|
+
query_params(signer_access_code: signer_access_code, type: sig_type, reuse: reuse)
|
|
355
|
+
)
|
|
356
|
+
request.headers['Content-Type'] = content_type
|
|
357
|
+
request.body = content
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Download the signer's signature image as raw bytes.
|
|
363
|
+
#
|
|
364
|
+
# @param signer_access_code [String]
|
|
365
|
+
# @param type [String] `signature` or `initial`
|
|
366
|
+
# @return [String] binary image body (ASCII-8BIT), e.g. raw PNG bytes
|
|
367
|
+
# @see GET /signature/{type}
|
|
368
|
+
# @example Download and save the signer's signature image
|
|
369
|
+
# png = client.signers.download_signature(
|
|
370
|
+
# signer_access_code: 'signer-access-code',
|
|
371
|
+
# type: 'signature'
|
|
372
|
+
# )
|
|
373
|
+
#
|
|
374
|
+
# # The SDK returns the raw response body as binary bytes (Content-Type: image/png):
|
|
375
|
+
# # => "\x89PNG\r\n\x1A\n..." # ASCII-8BIT String
|
|
376
|
+
# File.binwrite('signature.png', png)
|
|
377
|
+
def download_signature(signer_access_code:, type: 'signature')
|
|
378
|
+
sig_type = signature_type(type)
|
|
379
|
+
|
|
380
|
+
call_binary('Failed to download signer signature') do
|
|
381
|
+
http_get("signature/#{sig_type}", signer_access_code: signer_access_code)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
def assert_email!(email)
|
|
388
|
+
unless email && EMAIL_REGEX.match?(email)
|
|
389
|
+
raise ValidationError.new('Invalid email address', { email: email })
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def signer_payload(payload, require_full_name:, include_government_id: false)
|
|
394
|
+
raw = require_payload(payload, 'Signer payload')
|
|
395
|
+
p = raw.transform_keys(&:to_s)
|
|
396
|
+
|
|
397
|
+
full_name = p['full_name'] || p['name']
|
|
398
|
+
raise ValidationError.new('full_name is required') if require_full_name && full_name.to_s.strip.empty?
|
|
399
|
+
|
|
400
|
+
email = p['email']
|
|
401
|
+
assert_email!(email) if email && !email.to_s.empty?
|
|
402
|
+
|
|
403
|
+
body = body_params(
|
|
404
|
+
full_name: full_name,
|
|
405
|
+
email: email,
|
|
406
|
+
whatsapp_phone_number: p['whatsapp_phone_number'] || p['phone']
|
|
407
|
+
)
|
|
408
|
+
if include_government_id && !p['government_id'].nil?
|
|
409
|
+
body['government_id'] = p['government_id']
|
|
410
|
+
end
|
|
411
|
+
body
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def signature_type(type)
|
|
415
|
+
value = require_id(type, 'Signature type').to_s
|
|
416
|
+
return value if SIGNATURE_TYPES.include?(value)
|
|
417
|
+
|
|
418
|
+
raise ValidationError.new('Signature type must be signature or initial', { type: type })
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Assinafy
|
|
4
|
+
module Resources
|
|
5
|
+
# Workspace-scoped tag management.
|
|
6
|
+
#
|
|
7
|
+
# Tags are labels that can be attached to documents and templates for
|
|
8
|
+
# filtering and organization.
|
|
9
|
+
#
|
|
10
|
+
# See https://api.assinafy.com.br/v1/docs#tag for the full
|
|
11
|
+
# documentation of these endpoints.
|
|
12
|
+
class TagResource < BaseResource
|
|
13
|
+
# List tags in the workspace, ordered alphabetically by name.
|
|
14
|
+
#
|
|
15
|
+
# @param params [Hash] documented `search` query; additional deployment-specific keys are forwarded
|
|
16
|
+
# @param account_id_override [String, nil]
|
|
17
|
+
# @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: { ... } }`
|
|
18
|
+
# @see GET /accounts/{account_id}/tags
|
|
19
|
+
# @example List tags matching a search term
|
|
20
|
+
# # Request: GET /accounts/{account_id}/tags?search=doc
|
|
21
|
+
# client.tags.list(search: 'doc')
|
|
22
|
+
#
|
|
23
|
+
# # Response (unwrapped data payload):
|
|
24
|
+
# {
|
|
25
|
+
# data: [
|
|
26
|
+
# {
|
|
27
|
+
# 'id' => '1031f6544019bafc410c6c5317f4',
|
|
28
|
+
# 'name' => 'audit-doc-tag',
|
|
29
|
+
# 'color' => nil,
|
|
30
|
+
# 'created_at' => '2026-06-05T16:33:35Z',
|
|
31
|
+
# 'updated_at' => '2026-06-05T16:33:35Z'
|
|
32
|
+
# }
|
|
33
|
+
# # ... (each entry also carries 'resource' => 'tag')
|
|
34
|
+
# ],
|
|
35
|
+
# meta: { current_page: 1, per_page: 3, total: 13, last_page: 5 }
|
|
36
|
+
# }
|
|
37
|
+
def list(params = {}, account_id_override = nil)
|
|
38
|
+
acc_id = account_id(account_id_override)
|
|
39
|
+
|
|
40
|
+
call_list('Failed to list tags') do
|
|
41
|
+
http_get("accounts/#{acc_id}/tags", params)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Create a tag in the workspace.
|
|
46
|
+
#
|
|
47
|
+
# Returns 409 Conflict if a tag with the same name (case-insensitive)
|
|
48
|
+
# already exists.
|
|
49
|
+
#
|
|
50
|
+
# @param payload [Hash]
|
|
51
|
+
# @option payload [String] :name required tag display name
|
|
52
|
+
# @option payload [String, nil] :color optional 6-character hex color
|
|
53
|
+
# @param account_id_override [String, nil]
|
|
54
|
+
# @return [Hash] the created tag object
|
|
55
|
+
# @raise [Assinafy::ValidationError] if `:name` is missing or blank
|
|
56
|
+
# @see POST /accounts/{account_id}/tags
|
|
57
|
+
# @example Create a tag
|
|
58
|
+
# # Request: POST /accounts/{account_id}/tags
|
|
59
|
+
# # Body: { "name": "Contracts", "color": "ff8800" }
|
|
60
|
+
# client.tags.create(name: 'Contracts', color: 'ff8800')
|
|
61
|
+
#
|
|
62
|
+
# # Response (unwrapped data payload):
|
|
63
|
+
# {
|
|
64
|
+
# 'resource' => 'tag',
|
|
65
|
+
# 'id' => '1032009e69e366ca5adc879ef26c',
|
|
66
|
+
# 'name' => 'Contracts',
|
|
67
|
+
# 'color' => 'ff8800',
|
|
68
|
+
# 'created_at' => '2026-06-05T21:21:19Z',
|
|
69
|
+
# 'updated_at' => '2026-06-05T21:21:19Z'
|
|
70
|
+
# }
|
|
71
|
+
def create(payload, account_id_override = nil)
|
|
72
|
+
acc_id = account_id(account_id_override)
|
|
73
|
+
body = tag_payload(payload, require_name: true)
|
|
74
|
+
|
|
75
|
+
call('Failed to create tag') do
|
|
76
|
+
http_post("accounts/#{acc_id}/tags", body)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Update a tag's name and/or color. Documents and templates already
|
|
81
|
+
# attached to the tag keep their relationship; only the tag's own
|
|
82
|
+
# attributes change. Returns 409 Conflict if another tag already uses
|
|
83
|
+
# the new name (case-insensitive).
|
|
84
|
+
#
|
|
85
|
+
# At least one of `:name` or `:color` must be supplied: an empty payload
|
|
86
|
+
# raises, and a blank `:name` raises.
|
|
87
|
+
#
|
|
88
|
+
# @param tag_id [String]
|
|
89
|
+
# @param payload [Hash]
|
|
90
|
+
# @option payload [String] :name optional new name
|
|
91
|
+
# @option payload [String, nil] :color optional new color; nil clears it
|
|
92
|
+
# @param account_id_override [String, nil]
|
|
93
|
+
# @return [Hash] the updated tag object
|
|
94
|
+
# @raise [Assinafy::ValidationError] if the payload is empty or `:name` is blank
|
|
95
|
+
# @see PUT /accounts/{account_id}/tags/{tag_id}
|
|
96
|
+
# @example Rename a tag and recolor it
|
|
97
|
+
# # Request: PUT /accounts/{account_id}/tags/{tag_id}
|
|
98
|
+
# # Body: { "name": "Sales Contracts", "color": "112233" }
|
|
99
|
+
# client.tags.update('1032009e69e366ca5adc879ef26c',
|
|
100
|
+
# name: 'Sales Contracts', color: '112233')
|
|
101
|
+
#
|
|
102
|
+
# # Response (unwrapped data payload):
|
|
103
|
+
# {
|
|
104
|
+
# 'resource' => 'tag',
|
|
105
|
+
# 'id' => '1032009e69e366ca5adc879ef26c',
|
|
106
|
+
# 'name' => 'Sales Contracts',
|
|
107
|
+
# 'color' => '112233',
|
|
108
|
+
# 'created_at' => '2026-06-05T21:21:19Z',
|
|
109
|
+
# 'updated_at' => '2026-06-05T22:00:00Z'
|
|
110
|
+
# }
|
|
111
|
+
# @example Clear a tag's color (pass nil explicitly)
|
|
112
|
+
# # Request: PUT /accounts/{account_id}/tags/{tag_id}
|
|
113
|
+
# # Body: { "color": null }
|
|
114
|
+
# client.tags.update('1032009e69e366ca5adc879ef26c', color: nil)
|
|
115
|
+
# #=> { 'resource' => 'tag', 'id' => '1032009e69e366ca5adc879ef26c', 'color' => nil, ... }
|
|
116
|
+
def update(tag_id, payload, account_id_override = nil)
|
|
117
|
+
acc_id = account_id(account_id_override)
|
|
118
|
+
tid = require_id(tag_id, 'Tag ID')
|
|
119
|
+
body = tag_payload(payload, require_name: false)
|
|
120
|
+
|
|
121
|
+
call('Failed to update tag') do
|
|
122
|
+
http_put("accounts/#{acc_id}/tags/#{tid}", body)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Delete a tag. By default, deletion fails with 409 Conflict if the tag
|
|
127
|
+
# is attached to any document or template. Pass `force: true` to detach
|
|
128
|
+
# it from everything and delete it; the documents and templates
|
|
129
|
+
# themselves are not deleted.
|
|
130
|
+
#
|
|
131
|
+
# @param tag_id [String]
|
|
132
|
+
# @param account_id_override [String, nil]
|
|
133
|
+
# @param force [Boolean]
|
|
134
|
+
# @return [Hash] `{ 'deleted' => true }`
|
|
135
|
+
# @see DELETE /accounts/{account_id}/tags/{tag_id}
|
|
136
|
+
# @example Delete a tag, detaching it from documents and templates first
|
|
137
|
+
# # Request: DELETE /accounts/{account_id}/tags/{tag_id}?force=true
|
|
138
|
+
# client.tags.delete('1032009e69e366ca5adc879ef26c', force: true)
|
|
139
|
+
#
|
|
140
|
+
# # Response (unwrapped data payload):
|
|
141
|
+
# { 'deleted' => true }
|
|
142
|
+
def delete(tag_id, account_id_override = nil, force: false)
|
|
143
|
+
acc_id = account_id(account_id_override)
|
|
144
|
+
tid = require_id(tag_id, 'Tag ID')
|
|
145
|
+
force = require_boolean(force, 'force')
|
|
146
|
+
params = force ? { force: true } : {}
|
|
147
|
+
|
|
148
|
+
call('Failed to delete tag') do
|
|
149
|
+
http_delete("accounts/#{acc_id}/tags/#{tid}", params)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def tag_payload(payload, require_name:)
|
|
156
|
+
raw = require_payload(payload, 'Tag payload')
|
|
157
|
+
body = body_params(raw)
|
|
158
|
+
body['color'] = nil if explicit_nil_color?(raw)
|
|
159
|
+
|
|
160
|
+
validate_tag_name!(body, require_name: require_name)
|
|
161
|
+
|
|
162
|
+
if !require_name && body.empty?
|
|
163
|
+
raise ValidationError.new('Provide at least one of name or color to update')
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
body
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_tag_name!(body, require_name:)
|
|
170
|
+
has_name = body.key?('name')
|
|
171
|
+
blank = body['name'].to_s.strip.empty?
|
|
172
|
+
|
|
173
|
+
raise ValidationError.new('Tag name is required') if require_name && (!has_name || blank)
|
|
174
|
+
raise ValidationError.new('Tag name cannot be blank') if !require_name && has_name && blank
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def explicit_nil_color?(payload)
|
|
178
|
+
(payload.key?(:color) && payload[:color].nil?) ||
|
|
179
|
+
(payload.key?('color') && payload['color'].nil?)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|