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.
@@ -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