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,912 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Assinafy
|
|
4
|
+
module Resources
|
|
5
|
+
# Document upload, retrieval, download, lifecycle, and verification.
|
|
6
|
+
#
|
|
7
|
+
# See https://api.assinafy.com.br/v1/docs#document for the full
|
|
8
|
+
# documentation of these endpoints.
|
|
9
|
+
class DocumentResource < BaseResource
|
|
10
|
+
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
11
|
+
READY_STATUSES = %w[metadata_ready pending_signature certificated].freeze
|
|
12
|
+
FAILED_STATUSES = %w[failed rejected_by_signer rejected_by_user expired].freeze
|
|
13
|
+
ARTIFACT_TYPES = %w[original certificated certificate-page bundle pades].freeze
|
|
14
|
+
|
|
15
|
+
# Upload a PDF and create a document.
|
|
16
|
+
#
|
|
17
|
+
# @param source [String, Hash] either a path to a PDF on disk, or a
|
|
18
|
+
# Hash with `:file_path` (path) **or** `:buffer` + `:file_name` (raw bytes).
|
|
19
|
+
# @param options [Hash]
|
|
20
|
+
# @option options [String] :name optional display name for the document
|
|
21
|
+
# @option options [String] :account_id override the client default
|
|
22
|
+
# @return [Hash] document object
|
|
23
|
+
# @raise [Assinafy::ValidationError] on invalid input or empty/non-PDF file
|
|
24
|
+
# @raise [Assinafy::ApiError] on a non-2xx response
|
|
25
|
+
#
|
|
26
|
+
# @see POST /accounts/{account_id}/documents
|
|
27
|
+
# @example Upload a PDF from disk
|
|
28
|
+
# # Request: POST /accounts/{account_id}/documents (multipart/form-data)
|
|
29
|
+
# # Body: file=<binary application/pdf>, name="assinafy_audit_test.pdf"
|
|
30
|
+
# client.documents.upload('/tmp/contract.pdf', name: 'assinafy_audit_test.pdf')
|
|
31
|
+
#
|
|
32
|
+
# # Response (unwrapped data payload):
|
|
33
|
+
# {
|
|
34
|
+
# 'resource' => 'document',
|
|
35
|
+
# 'id' => '1032009d72b364f377ff270405cc',
|
|
36
|
+
# 'account_id' => 'account-id',
|
|
37
|
+
# 'template_id' => nil,
|
|
38
|
+
# 'name' => 'assinafy_audit_test.pdf',
|
|
39
|
+
# 'status' => 'uploaded',
|
|
40
|
+
# 'artifacts' => {
|
|
41
|
+
# 'original' => 'https://sandbox.assinafy.com.br/v1/documents/<id>/download/original'
|
|
42
|
+
# },
|
|
43
|
+
# 'is_closed' => false,
|
|
44
|
+
# 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1032009d72b364f377ff270405cc',
|
|
45
|
+
# 'decline_reason' => nil,
|
|
46
|
+
# 'declined_by' => nil,
|
|
47
|
+
# 'tags' => [],
|
|
48
|
+
# 'created_at' => '2026-06-05T21:21:12Z',
|
|
49
|
+
# 'updated_at' => '2026-06-05T21:21:13Z',
|
|
50
|
+
# 'pages' => []
|
|
51
|
+
# }
|
|
52
|
+
# @example Upload raw bytes from memory
|
|
53
|
+
# client.documents.upload(buffer: pdf_bytes, file_name: 'in_memory.pdf')
|
|
54
|
+
# @note The SDK enforces the `.pdf` extension and 25 MB limit; the API
|
|
55
|
+
# performs authoritative PDF-structure validation.
|
|
56
|
+
def upload(source, options = {})
|
|
57
|
+
buffer, file_name = read_source(source, max_bytes: MAX_UPLOAD_BYTES)
|
|
58
|
+
validate_upload!(buffer, file_name)
|
|
59
|
+
|
|
60
|
+
acc_id = account_id(options[:account_id])
|
|
61
|
+
|
|
62
|
+
@logger.info("Uploading document (#{buffer.bytesize} bytes)")
|
|
63
|
+
|
|
64
|
+
payload = { file: file_part(buffer, file_name, 'application/pdf') }
|
|
65
|
+
payload[:name] = options[:name] if options[:name]
|
|
66
|
+
|
|
67
|
+
document = call('Document upload failed') do
|
|
68
|
+
http_post("accounts/#{acc_id}/documents", payload)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
unless document.is_a?(Hash) && document['id']
|
|
72
|
+
raise ValidationError.new('Upload succeeded but no document ID was returned')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@logger.info("Document uploaded: #{document['id']}")
|
|
76
|
+
document
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# List documents for an account.
|
|
80
|
+
#
|
|
81
|
+
# @param params [Hash] query parameters (`status`, `method`, `search`, `sort`, `tags`, `page`, `per_page`)
|
|
82
|
+
# @param account_id_override [String, nil]
|
|
83
|
+
# @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: { current_page:, per_page:, total:, last_page: } }`
|
|
84
|
+
#
|
|
85
|
+
# @see GET /accounts/{account_id}/documents
|
|
86
|
+
# @example List the first page of documents
|
|
87
|
+
# # Request: GET /accounts/{account_id}/documents?per-page=3
|
|
88
|
+
# client.documents.list(per_page: 3)
|
|
89
|
+
#
|
|
90
|
+
# # Response (unwrapped data payload):
|
|
91
|
+
# {
|
|
92
|
+
# data: [
|
|
93
|
+
# {
|
|
94
|
+
# 'id' => '1031ff847e1aecdcf848f579cc77',
|
|
95
|
+
# 'account_id' => 'account-id',
|
|
96
|
+
# 'template_id' => nil,
|
|
97
|
+
# 'name' => 'audit.pdf',
|
|
98
|
+
# 'status' => 'metadata_ready',
|
|
99
|
+
# 'artifacts' => {
|
|
100
|
+
# 'original' => 'https://sandbox.assinafy.com.br/v1/documents/1031ff84.../download/original',
|
|
101
|
+
# 'thumbnail' => 'https://sandbox.assinafy.com.br/v1/documents/1031ff84.../thumbnail'
|
|
102
|
+
# },
|
|
103
|
+
# 'is_closed' => false,
|
|
104
|
+
# 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1031ff847e1aecdcf848f579cc77',
|
|
105
|
+
# 'decline_reason' => nil,
|
|
106
|
+
# 'declined_by' => nil,
|
|
107
|
+
# 'tags' => [],
|
|
108
|
+
# 'assignment' => nil,
|
|
109
|
+
# 'pages' => [
|
|
110
|
+
# { 'id' => '1031ff84c23f85c38503ff0324d6', 'number' => 1, 'height' => 1651,
|
|
111
|
+
# 'width' => 1275, 'download_url' => 'https://sandbox.assinafy.com.br/v1/documents/.../download' }
|
|
112
|
+
# ],
|
|
113
|
+
# 'created_at' => '2026-06-05T20:50:31Z',
|
|
114
|
+
# 'updated_at' => '2026-06-05T20:50:34Z'
|
|
115
|
+
# }
|
|
116
|
+
# # ... (one Hash per document)
|
|
117
|
+
# ],
|
|
118
|
+
# meta: { current_page: 1, per_page: 3, total: 14, last_page: 5 }
|
|
119
|
+
# }
|
|
120
|
+
def list(params = {}, account_id_override = nil)
|
|
121
|
+
acc_id = account_id(account_id_override)
|
|
122
|
+
|
|
123
|
+
call_list('Failed to list documents') do
|
|
124
|
+
http_get("accounts/#{acc_id}/documents", params)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Lightweight search over an account's documents (id/name/status/artifacts),
|
|
129
|
+
# without the heavier per-document detail returned by {#list}.
|
|
130
|
+
#
|
|
131
|
+
# @param query [String] free-text search term
|
|
132
|
+
# @param params [Hash] extra query parameters (`status`, `page`, `per_page`, ...)
|
|
133
|
+
# @param account_id_override [String, nil]
|
|
134
|
+
# @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: {..} | nil }`
|
|
135
|
+
# @see GET /accounts/{account_id}/documents/search
|
|
136
|
+
# @example Search documents by name
|
|
137
|
+
# # Request: GET /accounts/{account_id}/documents/search?search=contract
|
|
138
|
+
# client.documents.search('contract')
|
|
139
|
+
#
|
|
140
|
+
# # Response (unwrapped data payload):
|
|
141
|
+
# {
|
|
142
|
+
# data: [
|
|
143
|
+
# {
|
|
144
|
+
# 'id' => '103b0253fdc3607d342c49f9b55d',
|
|
145
|
+
# 'account_id' => 'account-id',
|
|
146
|
+
# 'template_id' => nil,
|
|
147
|
+
# 'name' => 'contract.pdf',
|
|
148
|
+
# 'status' => 'metadata_ready',
|
|
149
|
+
# 'artifacts' => { 'original' => 'https://...', 'thumbnail' => 'https://...' },
|
|
150
|
+
# 'is_closed' => false,
|
|
151
|
+
# 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/103b0253...',
|
|
152
|
+
# 'tags' => [],
|
|
153
|
+
# 'created_at' => '2026-07-20T15:53:37Z',
|
|
154
|
+
# 'updated_at' => '2026-07-20T15:53:41Z'
|
|
155
|
+
# }
|
|
156
|
+
# # ... (one Hash per matching document)
|
|
157
|
+
# ],
|
|
158
|
+
# meta: nil
|
|
159
|
+
# }
|
|
160
|
+
def search(query, params = {}, account_id_override = nil)
|
|
161
|
+
acc_id = account_id(account_id_override)
|
|
162
|
+
|
|
163
|
+
call_list('Failed to search documents') do
|
|
164
|
+
http_get("accounts/#{acc_id}/documents/search", params.merge(search: query))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# List the catalog of document status codes.
|
|
169
|
+
#
|
|
170
|
+
# @return [Array<Hash>] each entry has `code` and a `deletable` flag
|
|
171
|
+
# @see GET /documents/statuses
|
|
172
|
+
# @example List status codes
|
|
173
|
+
# # Request: GET /documents/statuses
|
|
174
|
+
# client.documents.statuses
|
|
175
|
+
#
|
|
176
|
+
# # Response (unwrapped data payload):
|
|
177
|
+
# [
|
|
178
|
+
# { 'code' => 'uploading', 'deletable' => false },
|
|
179
|
+
# { 'code' => 'uploaded', 'deletable' => false },
|
|
180
|
+
# { 'code' => 'metadata_processing', 'deletable' => false },
|
|
181
|
+
# { 'code' => 'metadata_ready', 'deletable' => true },
|
|
182
|
+
# { 'code' => 'expired', 'deletable' => true },
|
|
183
|
+
# { 'code' => 'certificating', 'deletable' => false },
|
|
184
|
+
# { 'code' => 'certificated', 'deletable' => false },
|
|
185
|
+
# { 'code' => 'rejected_by_signer', 'deletable' => true },
|
|
186
|
+
# { 'code' => 'pending_signature', 'deletable' => true },
|
|
187
|
+
# { 'code' => 'rejected_by_user', 'deletable' => true },
|
|
188
|
+
# { 'code' => 'failed', 'deletable' => true }
|
|
189
|
+
# ]
|
|
190
|
+
def statuses
|
|
191
|
+
call('Failed to list document statuses') do
|
|
192
|
+
http_get('documents/statuses')
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Fetch a document by ID.
|
|
197
|
+
#
|
|
198
|
+
# @param document_id [String]
|
|
199
|
+
# @return [Hash] document object (includes `assignment` once one exists, else nil)
|
|
200
|
+
# @see GET /documents/{document_id}
|
|
201
|
+
# @example Fetch a document
|
|
202
|
+
# # Request: GET /documents/{document_id}
|
|
203
|
+
# client.documents.details('1032009d72b364f377ff270405cc')
|
|
204
|
+
#
|
|
205
|
+
# # Response (unwrapped data payload):
|
|
206
|
+
# {
|
|
207
|
+
# 'resource' => 'document',
|
|
208
|
+
# 'id' => '1032009d72b364f377ff270405cc',
|
|
209
|
+
# 'account_id' => 'account-id',
|
|
210
|
+
# 'template_id' => nil,
|
|
211
|
+
# 'name' => 'assinafy_audit_test.pdf',
|
|
212
|
+
# 'status' => 'metadata_ready',
|
|
213
|
+
# 'artifacts' => {
|
|
214
|
+
# 'original' => 'https://sandbox.assinafy.com.br/v1/documents/1032009d.../download/original',
|
|
215
|
+
# 'thumbnail' => 'https://sandbox.assinafy.com.br/v1/documents/1032009d.../thumbnail'
|
|
216
|
+
# },
|
|
217
|
+
# 'is_closed' => false,
|
|
218
|
+
# 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1032009d72b364f377ff270405cc',
|
|
219
|
+
# 'decline_reason' => nil,
|
|
220
|
+
# 'declined_by' => nil,
|
|
221
|
+
# 'tags' => [],
|
|
222
|
+
# 'assignment' => nil,
|
|
223
|
+
# 'pages' => [
|
|
224
|
+
# { 'id' => '1032009db961c327b101a7fea34d', 'number' => 1, 'height' => 1651,
|
|
225
|
+
# 'width' => 1275, 'download_url' => 'https://sandbox.assinafy.com.br/v1/documents/.../download' }
|
|
226
|
+
# ],
|
|
227
|
+
# 'created_at' => '2026-06-05T21:21:12Z',
|
|
228
|
+
# 'updated_at' => '2026-06-05T21:21:15Z'
|
|
229
|
+
# }
|
|
230
|
+
def details(document_id)
|
|
231
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
232
|
+
|
|
233
|
+
call('Failed to fetch document details') do
|
|
234
|
+
http_get("documents/#{doc_id}")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
alias get details
|
|
239
|
+
|
|
240
|
+
# Rename a document.
|
|
241
|
+
#
|
|
242
|
+
# @param document_id [String]
|
|
243
|
+
# @param name [String] the new display name
|
|
244
|
+
# @return [Hash] the updated document object (envelope `data` unwrapped)
|
|
245
|
+
# @see PATCH /documents/{document_id}
|
|
246
|
+
# @example Rename a document
|
|
247
|
+
# # Request: PATCH /documents/{document_id}
|
|
248
|
+
# # Body: { "name": "renamed.pdf" }
|
|
249
|
+
# client.documents.rename('103b0253fdc3607d342c49f9b55d', 'renamed.pdf')
|
|
250
|
+
#
|
|
251
|
+
# # Response (unwrapped data payload):
|
|
252
|
+
# {
|
|
253
|
+
# 'resource' => 'document',
|
|
254
|
+
# 'id' => '103b0253fdc3607d342c49f9b55d',
|
|
255
|
+
# 'account_id' => 'account-id',
|
|
256
|
+
# 'name' => 'renamed.pdf',
|
|
257
|
+
# 'status' => 'metadata_ready',
|
|
258
|
+
# 'artifacts' => { 'original' => 'https://...', 'thumbnail' => 'https://...' },
|
|
259
|
+
# 'is_closed' => false,
|
|
260
|
+
# 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/103b0253...',
|
|
261
|
+
# 'tags' => [],
|
|
262
|
+
# 'created_at' => '2026-07-20T15:53:37Z',
|
|
263
|
+
# 'updated_at' => '2026-07-20T15:53:41Z'
|
|
264
|
+
# # ... (see #details for the full document shape)
|
|
265
|
+
# }
|
|
266
|
+
def rename(document_id, name)
|
|
267
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
268
|
+
new_name = require_present(name, 'Name')
|
|
269
|
+
|
|
270
|
+
call('Failed to rename document') do
|
|
271
|
+
http_patch("documents/#{doc_id}", body_params(name: new_name))
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Poll {#details} until the document reaches a {READY_STATUSES ready} status,
|
|
276
|
+
# raising if it reaches a {FAILED_STATUSES failed} status or the deadline elapses.
|
|
277
|
+
#
|
|
278
|
+
# @param document_id [String]
|
|
279
|
+
# @param max_wait_seconds [Integer] total deadline (default: 30)
|
|
280
|
+
# @param poll_interval_seconds [Integer] (default: 2)
|
|
281
|
+
# @return [Hash] the document once it is ready
|
|
282
|
+
# @raise [Assinafy::ValidationError] on timeout or terminal failed status
|
|
283
|
+
# @example Block until a freshly uploaded document is processed
|
|
284
|
+
# # Polls GET /documents/{document_id} every 2s until status is ready.
|
|
285
|
+
# client.documents.wait_until_ready('1032009d72b364f377ff270405cc', max_wait_seconds: 30)
|
|
286
|
+
#
|
|
287
|
+
# # Response (unwrapped data payload): same shape as #details, with a ready status:
|
|
288
|
+
# {
|
|
289
|
+
# 'resource' => 'document',
|
|
290
|
+
# 'id' => '1032009d72b364f377ff270405cc',
|
|
291
|
+
# 'status' => 'metadata_ready', # one of READY_STATUSES
|
|
292
|
+
# # ... (see #details for the full document shape)
|
|
293
|
+
# }
|
|
294
|
+
def wait_until_ready(document_id, max_wait_seconds: 30, poll_interval_seconds: 2)
|
|
295
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
296
|
+
unless max_wait_seconds.is_a?(Numeric) && max_wait_seconds > 0 &&
|
|
297
|
+
poll_interval_seconds.is_a?(Numeric) && poll_interval_seconds > 0
|
|
298
|
+
raise ValidationError.new('Wait and poll intervals must be positive numbers')
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
clock = Process::CLOCK_MONOTONIC
|
|
302
|
+
deadline = Process.clock_gettime(clock) + max_wait_seconds
|
|
303
|
+
attempts = 0
|
|
304
|
+
|
|
305
|
+
@logger.info("Waiting for document to be ready: #{doc_id}")
|
|
306
|
+
|
|
307
|
+
while Process.clock_gettime(clock) < deadline
|
|
308
|
+
attempts += 1
|
|
309
|
+
begin
|
|
310
|
+
doc = details(doc_id)
|
|
311
|
+
status = doc['status'] || 'unknown'
|
|
312
|
+
|
|
313
|
+
@logger.debug("Document status check #{attempts}: #{status}")
|
|
314
|
+
|
|
315
|
+
return doc if READY_STATUSES.include?(status)
|
|
316
|
+
|
|
317
|
+
if FAILED_STATUSES.include?(status)
|
|
318
|
+
raise ValidationError.new("Document processing failed with status: #{status}")
|
|
319
|
+
end
|
|
320
|
+
rescue NetworkError => e
|
|
321
|
+
@logger.warn("Error checking document status: #{e.message}")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
remaining = deadline - Process.clock_gettime(clock)
|
|
325
|
+
break unless remaining > 0
|
|
326
|
+
|
|
327
|
+
sleep([poll_interval_seconds, remaining].min)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
raise ValidationError.new(
|
|
331
|
+
'Timeout waiting for document to be ready',
|
|
332
|
+
{ document_id: doc_id, attempts: attempts }
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Download a document artifact as raw bytes.
|
|
337
|
+
#
|
|
338
|
+
# @param document_id [String]
|
|
339
|
+
# @param artifact_name [String] `original`, `certificated`, `certificate-page`, `pades`, or
|
|
340
|
+
# `bundle` (default `certificated`)
|
|
341
|
+
# @return [String] binary PDF body
|
|
342
|
+
# @raise [Assinafy::ValidationError] on unknown artifact type
|
|
343
|
+
# @see GET /documents/{document_id}/download/{artifact_name}
|
|
344
|
+
# @example Download the original upload and save it to disk
|
|
345
|
+
# # Request: GET /documents/{document_id}/download/original
|
|
346
|
+
# bytes = client.documents.download('1032009d72b364f377ff270405cc', 'original')
|
|
347
|
+
#
|
|
348
|
+
# # Response: raw bytes of the PDF (NOT a JSON envelope), e.g. a 607-byte String:
|
|
349
|
+
# bytes.class # => String
|
|
350
|
+
# bytes.bytesize # => 607
|
|
351
|
+
# File.binwrite('original.pdf', bytes)
|
|
352
|
+
def download(document_id, artifact_name = 'certificated')
|
|
353
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
354
|
+
art = artifact_type(artifact_name)
|
|
355
|
+
|
|
356
|
+
call_binary('Failed to download document') do
|
|
357
|
+
http_get("documents/#{doc_id}/download/#{art}")
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Download the document thumbnail (PNG/JPEG bytes).
|
|
362
|
+
#
|
|
363
|
+
# @param document_id [String]
|
|
364
|
+
# @return [String] binary image body
|
|
365
|
+
# @see GET /documents/{document_id}/thumbnail
|
|
366
|
+
# @example Download the thumbnail and save it
|
|
367
|
+
# # Request: GET /documents/{document_id}/thumbnail
|
|
368
|
+
# bytes = client.documents.thumbnail('1032009d72b364f377ff270405cc')
|
|
369
|
+
#
|
|
370
|
+
# # Response: raw image bytes (NOT a JSON envelope), e.g. a 4973-byte JPEG String:
|
|
371
|
+
# bytes.class # => String
|
|
372
|
+
# bytes.byteslice(0, 4) # => "\xFF\xD8\xFF\xE0" (JPEG magic bytes)
|
|
373
|
+
# File.binwrite('thumb.jpg', bytes)
|
|
374
|
+
def thumbnail(document_id)
|
|
375
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
376
|
+
|
|
377
|
+
call_binary('Failed to download document thumbnail') do
|
|
378
|
+
http_get("documents/#{doc_id}/thumbnail")
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Download a single page artifact.
|
|
383
|
+
#
|
|
384
|
+
# @param document_id [String]
|
|
385
|
+
# @param page_id [String]
|
|
386
|
+
# @return [String] binary image body
|
|
387
|
+
# @see GET /documents/{document_id}/pages/{page_id}/download
|
|
388
|
+
# @example Download a single page image
|
|
389
|
+
# # Request: GET /documents/{document_id}/pages/{page_id}/download
|
|
390
|
+
# bytes = client.documents.download_page('1032009d72b364f377ff270405cc',
|
|
391
|
+
# '1032009db961c327b101a7fea34d')
|
|
392
|
+
#
|
|
393
|
+
# # Response: raw image bytes (NOT a JSON envelope):
|
|
394
|
+
# bytes.class # => String
|
|
395
|
+
# File.binwrite('page-1.png', bytes)
|
|
396
|
+
def download_page(document_id, page_id)
|
|
397
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
398
|
+
pid = require_id(page_id, 'Page ID')
|
|
399
|
+
|
|
400
|
+
call_binary('Failed to download page') do
|
|
401
|
+
http_get("documents/#{doc_id}/pages/#{pid}/download")
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# List the activity log for a document.
|
|
406
|
+
#
|
|
407
|
+
# @param document_id [String]
|
|
408
|
+
# @return [Array<Hash>] newest-first activity entries (empty Array when there are none)
|
|
409
|
+
# @see GET /documents/{documentId}/activities
|
|
410
|
+
# @example List a document's activity log
|
|
411
|
+
# # Request: GET /documents/{document_id}/activities
|
|
412
|
+
# client.documents.activities('1032009d72b364f377ff270405cc')
|
|
413
|
+
#
|
|
414
|
+
# # Response (unwrapped data payload):
|
|
415
|
+
# [
|
|
416
|
+
# {
|
|
417
|
+
# 'id' => 8304,
|
|
418
|
+
# 'event' => 'document_metadata_ready',
|
|
419
|
+
# 'message' => 'Documento processado.',
|
|
420
|
+
# 'payload' => [],
|
|
421
|
+
# 'origin' => nil,
|
|
422
|
+
# 'created_at' => '2026-06-05T21:21:15Z'
|
|
423
|
+
# },
|
|
424
|
+
# {
|
|
425
|
+
# 'id' => 8303,
|
|
426
|
+
# 'event' => 'document_uploaded',
|
|
427
|
+
# 'message' => 'Documento criado.',
|
|
428
|
+
# 'payload' => [],
|
|
429
|
+
# 'origin' => { 'ip' => '99.75.13.162', 'user-agent' => 'assinafy-ruby-sdk/1.3.1' },
|
|
430
|
+
# 'created_at' => '2026-06-05T21:21:13Z'
|
|
431
|
+
# }
|
|
432
|
+
# ]
|
|
433
|
+
def activities(document_id)
|
|
434
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
435
|
+
|
|
436
|
+
result = call('Failed to fetch document activities') do
|
|
437
|
+
http_get("documents/#{doc_id}/activities")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
result || []
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Permanently delete a document. Only allowed for "deletable" statuses.
|
|
444
|
+
#
|
|
445
|
+
# @param document_id [String]
|
|
446
|
+
# @return [nil]
|
|
447
|
+
# @see DELETE /documents/{documentId}
|
|
448
|
+
# @example Delete a deletable document
|
|
449
|
+
# # Request: DELETE /documents/{document_id}
|
|
450
|
+
# client.documents.delete('1032009d72b364f377ff270405cc')
|
|
451
|
+
#
|
|
452
|
+
# # Response: the API returns { "status": 200, "data": [] }; the SDK returns nil.
|
|
453
|
+
# # => nil
|
|
454
|
+
def delete(document_id)
|
|
455
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
456
|
+
|
|
457
|
+
call_void('Failed to delete document') do
|
|
458
|
+
http_delete("documents/#{doc_id}")
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Create a document from a template, optionally creating its virtual assignment.
|
|
463
|
+
#
|
|
464
|
+
# @param template_id [String]
|
|
465
|
+
# @param signers_or_payload [Array<Hash>, Hash] signer references (`role_id` + `id`)
|
|
466
|
+
# or a full payload Hash. When a Hash is passed, `options` is merged into it.
|
|
467
|
+
# @param options [Hash] additional body fields (`name`, `message`,
|
|
468
|
+
# `editor_fields`, `expires_at`, ...)
|
|
469
|
+
# @param account_id_override [String, nil]
|
|
470
|
+
# @return [Hash] document object with an embedded `assignment` (signers, items, signing_urls)
|
|
471
|
+
#
|
|
472
|
+
# @see POST /accounts/{account_id}/templates/{template_id}/documents
|
|
473
|
+
# @example Create a document from a template with two signers
|
|
474
|
+
# # Request: POST /accounts/{account_id}/templates/{template_id}/documents
|
|
475
|
+
# # Body: {
|
|
476
|
+
# # "name": "sample-contract.pdf",
|
|
477
|
+
# # "message": "Message to the signers",
|
|
478
|
+
# # "signers": [
|
|
479
|
+
# # { "role_id": "fa8c14f3...", "id": "fa8c140c...", "verification_method": "Email",
|
|
480
|
+
# # "notification_methods": ["Email"], "step": 1 }
|
|
481
|
+
# # ],
|
|
482
|
+
# # "expires_at": "2024-07-30T23:59:00Z"
|
|
483
|
+
# # }
|
|
484
|
+
# client.documents.create_from_template(
|
|
485
|
+
# '60f720572d7fecf7c16c8463',
|
|
486
|
+
# [{ role_id: 'fa8c14f3...', id: 'fa8c140c...' }],
|
|
487
|
+
# name: 'sample-contract.pdf', message: 'Message to the signers'
|
|
488
|
+
# )
|
|
489
|
+
#
|
|
490
|
+
# # Response (unwrapped data payload):
|
|
491
|
+
# {
|
|
492
|
+
# 'resource' => 'document',
|
|
493
|
+
# 'id' => 'fa8c140c614c928f7e7efa086b2',
|
|
494
|
+
# 'account_id' => '1a',
|
|
495
|
+
# 'template_id' => 'fa8c140b5ee344f8e48236ed284',
|
|
496
|
+
# 'name' => 'sample-contract.pdf',
|
|
497
|
+
# 'status' => 'uploaded',
|
|
498
|
+
# 'assignment' => {
|
|
499
|
+
# 'id' => 'fa8c140ccd5781b079738d19e95',
|
|
500
|
+
# 'method' => 'virtual',
|
|
501
|
+
# 'signers' => [{ 'id' => 'fa8c140c...', 'full_name' => 'Suzana Cordeiro',
|
|
502
|
+
# 'email' => 'signer@example.com', 'has_accepted_terms' => false }],
|
|
503
|
+
# 'summary' => { 'signer_count' => 1, 'completed_count' => 0, 'signers' => [] },
|
|
504
|
+
# 'signing_urls' => [{ 'signer_id' => 'customid1', 'url' => 'https://.../sign/...' }]
|
|
505
|
+
# # ... (also items, copy_receivers, expires_at, message)
|
|
506
|
+
# },
|
|
507
|
+
# 'tags' => [{ 'id' => 'ab12cd34...', 'name' => 'Onboarding' }],
|
|
508
|
+
# 'created_at' => '2024-07-23T15:05:17Z',
|
|
509
|
+
# 'updated_at' => '2024-07-23T15:05:17Z'
|
|
510
|
+
# # ... (artifacts, pages, is_closed, decline_reason; see docs for full shape)
|
|
511
|
+
# }
|
|
512
|
+
def create_from_template(template_id, signers_or_payload, options = {}, account_id_override = nil)
|
|
513
|
+
tmpl_id = require_id(template_id, 'Template ID')
|
|
514
|
+
acc_id = account_id(account_id_override)
|
|
515
|
+
body = template_body(signers_or_payload, options)
|
|
516
|
+
|
|
517
|
+
@logger.info("Creating document from template #{tmpl_id} for account #{acc_id}")
|
|
518
|
+
|
|
519
|
+
call('Failed to create document from template') do
|
|
520
|
+
http_post("accounts/#{acc_id}/templates/#{tmpl_id}/documents", body)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Estimate the cost of creating a document from a template without consuming credits.
|
|
525
|
+
#
|
|
526
|
+
# @param template_id [String]
|
|
527
|
+
# @param signers_or_payload [Array<Hash>, Hash] signers with required +role_id+ and
|
|
528
|
+
# optional +verification_method+ / +notification_methods+; signer IDs are not required
|
|
529
|
+
# @param account_id_override [String, nil]
|
|
530
|
+
# @return [Hash] cost breakdown with current account balances
|
|
531
|
+
#
|
|
532
|
+
# @see POST /accounts/{account_id}/templates/{template_id}/documents/estimate-cost
|
|
533
|
+
# @example Estimate cost before creating from a template
|
|
534
|
+
# # Request: POST /accounts/{account_id}/templates/{template_id}/documents/estimate-cost
|
|
535
|
+
# # Body: { "signers": [{ "role_id": "fa8c14f3...", "notification_methods": ["Email"] }] }
|
|
536
|
+
# client.documents.estimate_cost_from_template(
|
|
537
|
+
# '60f720572d7fecf7c16c8463',
|
|
538
|
+
# [{ role_id: 'fa8c14f3...', notification_methods: ['Email'] }]
|
|
539
|
+
# )
|
|
540
|
+
#
|
|
541
|
+
# # Response (unwrapped data payload):
|
|
542
|
+
# {
|
|
543
|
+
# 'documents' => 1,
|
|
544
|
+
# 'credits' => 0,
|
|
545
|
+
# 'needs_extra_document' => false,
|
|
546
|
+
# 'extra_document_cost' => 0,
|
|
547
|
+
# 'total_credits' => 0,
|
|
548
|
+
# 'breakdown' => [], # [{ 'code'=>'NotificationWhatsapp', 'cost'=>0.45, 'quantity'=>1, ... }]
|
|
549
|
+
# 'document_balance' => 62,
|
|
550
|
+
# 'credit_balance' => 0,
|
|
551
|
+
# 'has_sufficient_resources' => true,
|
|
552
|
+
# 'blocking_reason' => nil, # e.g. 'PendingPayment' / 'InsufficientDocuments' when blocked
|
|
553
|
+
# 'message' => nil
|
|
554
|
+
# }
|
|
555
|
+
def estimate_cost_from_template(template_id, signers_or_payload, account_id_override = nil)
|
|
556
|
+
tmpl_id = require_id(template_id, 'Template ID')
|
|
557
|
+
acc_id = account_id(account_id_override)
|
|
558
|
+
body = template_body(signers_or_payload)
|
|
559
|
+
|
|
560
|
+
call('Failed to estimate cost from template') do
|
|
561
|
+
http_post("accounts/#{acc_id}/templates/#{tmpl_id}/documents/estimate-cost", body)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Verify a certificated document by its signature hash.
|
|
566
|
+
#
|
|
567
|
+
# @param hash [String]
|
|
568
|
+
# @return [Hash] verification result; `is_valid` is false when the hash is unknown
|
|
569
|
+
# @see GET /documents/{signature_hash}/verify
|
|
570
|
+
# @example Verify a certificated document
|
|
571
|
+
# # Request: GET /documents/{signature_hash}/verify
|
|
572
|
+
# client.documents.verify('FE32EDDADE7CBDDCBB934E7402047450B0E59C02')
|
|
573
|
+
#
|
|
574
|
+
# # Response (unwrapped data payload) - verified:
|
|
575
|
+
# {
|
|
576
|
+
# 'hash' => 'FE32EDDADE7CBDDCBB934E7402047450B0E59C02',
|
|
577
|
+
# 'id' => '63ddb172402799bfc991d10d',
|
|
578
|
+
# 'status' => 'certificated',
|
|
579
|
+
# 'page_count' => '1',
|
|
580
|
+
# 'signer_count' => '1',
|
|
581
|
+
# 'completed_count' => 1,
|
|
582
|
+
# 'completed_at' => '2023-01-27T19:27:44Z',
|
|
583
|
+
# 'verified_at' => '2023-01-27T19:27:46Z',
|
|
584
|
+
# 'is_valid' => true,
|
|
585
|
+
# 'message' => ''
|
|
586
|
+
# }
|
|
587
|
+
# # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, 'status' => nil,
|
|
588
|
+
# # 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
|
|
589
|
+
def verify(hash)
|
|
590
|
+
h = require_id(hash, 'Signature hash')
|
|
591
|
+
|
|
592
|
+
call('Failed to verify document') do
|
|
593
|
+
http_get("documents/#{h}/verify")
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Fetch the unauthenticated, public-facing metadata of a document. The
|
|
598
|
+
# OpenAPI declares the full Document schema, while the current sandbox
|
|
599
|
+
# returns the smaller payload shown below; the SDK passes either through.
|
|
600
|
+
#
|
|
601
|
+
# @param document_id [String]
|
|
602
|
+
# @return [Hash] a Document Hash, or the current sandbox's minimal metadata Hash
|
|
603
|
+
# @see GET /public/documents/{document_id}
|
|
604
|
+
# @example Fetch public-facing document info (no auth required)
|
|
605
|
+
# # Request: GET /public/documents/{document_id}
|
|
606
|
+
# client.documents.public_info('39adfe3r5a3a')
|
|
607
|
+
#
|
|
608
|
+
# # Response (unwrapped data payload):
|
|
609
|
+
# {
|
|
610
|
+
# 'resource' => 'document',
|
|
611
|
+
# 'id' => 'doc1',
|
|
612
|
+
# 'name' => '1.pdf',
|
|
613
|
+
# 'page_count' => '1',
|
|
614
|
+
# 'created_by' => 'John Smith'
|
|
615
|
+
# }
|
|
616
|
+
def public_info(document_id)
|
|
617
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
618
|
+
|
|
619
|
+
call('Failed to fetch public document info') do
|
|
620
|
+
http_get("public/documents/#{doc_id}")
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Send a 6-digit access token for the document to a signer (public endpoint).
|
|
625
|
+
# The current OpenAPI permits no body or an `{ email: }` body, while the
|
|
626
|
+
# deployed sandbox requires `{ recipient:, channel: }` when a recipient is supplied.
|
|
627
|
+
#
|
|
628
|
+
# @param document_id [String]
|
|
629
|
+
# @param recipient [String, nil] deployed-API email address or WhatsApp phone number
|
|
630
|
+
# @param channel [String, nil] deployed-API `email` or `whatsapp` channel
|
|
631
|
+
# @param email [String, nil] email address for the current OpenAPI request shape
|
|
632
|
+
# @return [nil, Hash] `nil` for the OpenAPI's no-data envelope; the current
|
|
633
|
+
# sandbox returns `{ 'document' => {..}, 'channel' => String, 'recipient' => String }`
|
|
634
|
+
# @see PUT /public/documents/{document_id}/send-token
|
|
635
|
+
# @example Ask the API to use the document's signer contact (no auth required)
|
|
636
|
+
# client.documents.send_token('document-id')
|
|
637
|
+
#
|
|
638
|
+
# @example Email a signer using the current OpenAPI request (no auth required)
|
|
639
|
+
# # Request: PUT /public/documents/{document_id}/send-token
|
|
640
|
+
# # Body: { "email": "signer@example.com" }
|
|
641
|
+
# client.documents.send_token('document-id', email: 'signer@example.com')
|
|
642
|
+
#
|
|
643
|
+
# @example Use the current sandbox request shape
|
|
644
|
+
# # Body: { "recipient": "signer@example.com", "channel": "email" }
|
|
645
|
+
# client.documents.send_token('document-id', recipient: 'signer@example.com', channel: 'email')
|
|
646
|
+
#
|
|
647
|
+
# # Current sandbox response (unwrapped data payload):
|
|
648
|
+
# {
|
|
649
|
+
# 'document' => {
|
|
650
|
+
# 'resource' => 'document',
|
|
651
|
+
# 'id' => 'doc1',
|
|
652
|
+
# 'name' => '1.pdf',
|
|
653
|
+
# 'page_count' => '1',
|
|
654
|
+
# 'created_by' => 'John Smith'
|
|
655
|
+
# },
|
|
656
|
+
# 'channel' => 'email',
|
|
657
|
+
# 'recipient' => 'signer@example.com'
|
|
658
|
+
# }
|
|
659
|
+
# # => nil when the API returns the documented no-data envelope
|
|
660
|
+
def send_token(document_id, recipient: nil, channel: nil, email: nil)
|
|
661
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
662
|
+
if email.nil? && recipient.nil? && channel.nil?
|
|
663
|
+
payload = nil
|
|
664
|
+
elsif !email.nil?
|
|
665
|
+
if recipient || channel
|
|
666
|
+
raise ValidationError.new('Use either email or recipient/channel, not both')
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
payload = { email: require_present(email, 'Email') }
|
|
670
|
+
else
|
|
671
|
+
require_present(recipient, 'Recipient')
|
|
672
|
+
delivery_channel = require_present(channel, 'Channel').to_s
|
|
673
|
+
unless %w[email whatsapp].include?(delivery_channel)
|
|
674
|
+
raise ValidationError.new('Channel must be email or whatsapp')
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
payload = { recipient: recipient, channel: delivery_channel }
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
call('Failed to send signer token') do
|
|
681
|
+
http_put("public/documents/#{doc_id}/send-token", payload && body_params(payload))
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# List tags attached to a document.
|
|
686
|
+
#
|
|
687
|
+
# @param document_id [String]
|
|
688
|
+
# @param account_id_override [String, nil]
|
|
689
|
+
# @return [Array<Hash>] the document's tag objects
|
|
690
|
+
# @see GET /accounts/{account_id}/documents/{document_id}/tags
|
|
691
|
+
# @example List the tags attached to a document
|
|
692
|
+
# # Request: GET /accounts/{account_id}/documents/{document_id}/tags
|
|
693
|
+
# client.documents.list_tags('1032009d72b364f377ff270405cc')
|
|
694
|
+
#
|
|
695
|
+
# # Response (unwrapped data payload):
|
|
696
|
+
# [
|
|
697
|
+
# {
|
|
698
|
+
# 'id' => '1032009e69e366ca5adc879ef26c',
|
|
699
|
+
# 'name' => 'audit-e2e-renamed',
|
|
700
|
+
# 'color' => 'ff8800',
|
|
701
|
+
# 'created_at' => '2026-06-05T21:21:19Z',
|
|
702
|
+
# 'updated_at' => '2026-06-05T21:21:19Z'
|
|
703
|
+
# }
|
|
704
|
+
# ]
|
|
705
|
+
def list_tags(document_id, account_id_override = nil)
|
|
706
|
+
acc_id = account_id(account_id_override)
|
|
707
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
708
|
+
|
|
709
|
+
call('Failed to list document tags') do
|
|
710
|
+
http_get("accounts/#{acc_id}/documents/#{doc_id}/tags")
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Replace the document's full tag set. Passing an empty array detaches
|
|
715
|
+
# all tags from the document.
|
|
716
|
+
#
|
|
717
|
+
# @param document_id [String]
|
|
718
|
+
# @param tags [Array<String>] tag IDs per OpenAPI; the deployed sandbox also accepts existing names
|
|
719
|
+
# @param account_id_override [String, nil]
|
|
720
|
+
# @return [Array<Hash>] the document's full tag set after replacement (empty Array when detaching all)
|
|
721
|
+
# @see PUT /accounts/{account_id}/documents/{document_id}/tags
|
|
722
|
+
# @example Replace the tag set with a single tag
|
|
723
|
+
# # Request: PUT /accounts/{account_id}/documents/{document_id}/tags
|
|
724
|
+
# # Body: { "tags": ["ab12c09f3e709a8a1c82d69b145"] }
|
|
725
|
+
# client.documents.replace_tags('1032009d72b364f377ff270405cc', ['ab12c09f3e709a8a1c82d69b145'])
|
|
726
|
+
#
|
|
727
|
+
# # Response (unwrapped data payload):
|
|
728
|
+
# [
|
|
729
|
+
# {
|
|
730
|
+
# 'id' => 'ab12c09f3e709a8a1c82d69b145',
|
|
731
|
+
# 'name' => 'Contracts',
|
|
732
|
+
# 'color' => nil,
|
|
733
|
+
# 'created_at' => '2026-05-14T12:00:00Z',
|
|
734
|
+
# 'updated_at' => '2026-05-14T12:00:00Z'
|
|
735
|
+
# }
|
|
736
|
+
# ]
|
|
737
|
+
# # Passing [] detaches all tags and returns [].
|
|
738
|
+
def replace_tags(document_id, tags, account_id_override = nil)
|
|
739
|
+
acc_id = account_id(account_id_override)
|
|
740
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
741
|
+
|
|
742
|
+
call('Failed to replace document tags') do
|
|
743
|
+
http_put("accounts/#{acc_id}/documents/#{doc_id}/tags",
|
|
744
|
+
body_params(tags: tag_names(tags, allow_empty: true)))
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Attach additional tags to a document without removing existing tags.
|
|
749
|
+
#
|
|
750
|
+
# @param document_id [String]
|
|
751
|
+
# @param tags [Array<String>] tag IDs per OpenAPI; the deployed sandbox also accepts existing names
|
|
752
|
+
# @param account_id_override [String, nil]
|
|
753
|
+
# @return [Array<Hash>] the document's full tag set after the append
|
|
754
|
+
# @see POST /accounts/{account_id}/documents/{document_id}/tags
|
|
755
|
+
# @example Attach a tag without removing existing ones
|
|
756
|
+
# # Request: POST /accounts/{account_id}/documents/{document_id}/tags
|
|
757
|
+
# # Body: { "tags": ["1032009e69e366ca5adc879ef26c"] }
|
|
758
|
+
# client.documents.append_tags('1032009d72b364f377ff270405cc', ['1032009e69e366ca5adc879ef26c'])
|
|
759
|
+
#
|
|
760
|
+
# # Response (unwrapped data payload):
|
|
761
|
+
# [
|
|
762
|
+
# {
|
|
763
|
+
# 'id' => '1032009e69e366ca5adc879ef26c',
|
|
764
|
+
# 'name' => 'audit-e2e-renamed',
|
|
765
|
+
# 'color' => 'ff8800',
|
|
766
|
+
# 'created_at' => '2026-06-05T21:21:19Z',
|
|
767
|
+
# 'updated_at' => '2026-06-05T21:21:19Z'
|
|
768
|
+
# }
|
|
769
|
+
# ]
|
|
770
|
+
def append_tags(document_id, tags, account_id_override = nil)
|
|
771
|
+
acc_id = account_id(account_id_override)
|
|
772
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
773
|
+
|
|
774
|
+
call('Failed to append document tags') do
|
|
775
|
+
http_post("accounts/#{acc_id}/documents/#{doc_id}/tags",
|
|
776
|
+
body_params(tags: tag_names(tags)))
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Detach a single tag from a document. The tag itself is not deleted.
|
|
781
|
+
#
|
|
782
|
+
# @param document_id [String]
|
|
783
|
+
# @param tag_id [String]
|
|
784
|
+
# @param account_id_override [String, nil]
|
|
785
|
+
# @return [Hash] `{ 'detached' => true }`
|
|
786
|
+
# @see DELETE /accounts/{account_id}/documents/{document_id}/tags/{tag_id}
|
|
787
|
+
# @example Detach a single tag from a document
|
|
788
|
+
# # Request: DELETE /accounts/{account_id}/documents/{document_id}/tags/{tag_id}
|
|
789
|
+
# client.documents.detach_tag('1032009d72b364f377ff270405cc', 'fa8c09f3e709a8a1c82d69b1454')
|
|
790
|
+
#
|
|
791
|
+
# # Response (unwrapped data payload):
|
|
792
|
+
# { 'detached' => true }
|
|
793
|
+
# # Detaching a tag that was not attached is a no-op (still returns { 'detached' => true }).
|
|
794
|
+
def detach_tag(document_id, tag_id, account_id_override = nil)
|
|
795
|
+
acc_id = account_id(account_id_override)
|
|
796
|
+
doc_id = require_id(document_id, 'Document ID')
|
|
797
|
+
tid = require_id(tag_id, 'Tag ID')
|
|
798
|
+
|
|
799
|
+
call('Failed to detach document tag') do
|
|
800
|
+
http_delete("accounts/#{acc_id}/documents/#{doc_id}/tags/#{tid}")
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Convenience: true when the document is `certificated`, or when the
|
|
805
|
+
# embedded assignment summary reports all signers complete.
|
|
806
|
+
#
|
|
807
|
+
# @param document_id [String]
|
|
808
|
+
# @return [Boolean]
|
|
809
|
+
# @example Check whether every signer has completed
|
|
810
|
+
# # Fetches GET /documents/{document_id} and inspects status + assignment.summary.
|
|
811
|
+
# client.documents.fully_signed?('1032009d72b364f377ff270405cc')
|
|
812
|
+
#
|
|
813
|
+
# # Return value (computed locally from the document, not a server payload):
|
|
814
|
+
# # => false (true when status == 'certificated', or every signer in
|
|
815
|
+
# # assignment.summary is completed)
|
|
816
|
+
def fully_signed?(document_id)
|
|
817
|
+
doc = details(document_id)
|
|
818
|
+
return true if doc['status'] == 'certificated'
|
|
819
|
+
|
|
820
|
+
summary = doc.dig('assignment', 'summary')
|
|
821
|
+
if summary && summary['signer_count'].is_a?(Integer)
|
|
822
|
+
summary['signer_count'] > 0 && summary['signer_count'] == summary['completed_count']
|
|
823
|
+
else
|
|
824
|
+
false
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Convenience: derive a {signed, total, pending, percentage} progress
|
|
829
|
+
# Hash from the document's assignment summary.
|
|
830
|
+
#
|
|
831
|
+
# @param document_id [String]
|
|
832
|
+
# @return [Hash{Symbol=>Integer,Float}]
|
|
833
|
+
# @example Derive signing progress from the document's assignment summary
|
|
834
|
+
# # Fetches GET /documents/{document_id} and reduces assignment.summary locally.
|
|
835
|
+
# client.documents.signing_progress('1032009d72b364f377ff270405cc')
|
|
836
|
+
#
|
|
837
|
+
# # Return value (computed locally; percentage is signed/total rounded to 2 decimals):
|
|
838
|
+
# { signed: 0, total: 0, pending: 0, percentage: 0.0 }
|
|
839
|
+
def signing_progress(document_id)
|
|
840
|
+
doc = details(document_id)
|
|
841
|
+
summary = doc.dig('assignment', 'summary')
|
|
842
|
+
signers = doc.dig('assignment', 'signers') || []
|
|
843
|
+
|
|
844
|
+
total = (summary && summary['signer_count']) || signers.length
|
|
845
|
+
signed = (summary && summary['completed_count']) || 0
|
|
846
|
+
pending = [total - signed, 0].max
|
|
847
|
+
percentage = total > 0 ? (signed.to_f / total * 10_000).round / 100.0 : 0.0
|
|
848
|
+
|
|
849
|
+
{ signed: signed, total: total, pending: pending, percentage: percentage }
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
private
|
|
853
|
+
|
|
854
|
+
def validate_upload!(buffer, file_name)
|
|
855
|
+
if buffer.nil? || buffer.bytesize == 0
|
|
856
|
+
raise ValidationError.new('File buffer is empty', { file_name: file_name })
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
unless file_name.to_s.downcase.end_with?('.pdf')
|
|
860
|
+
raise ValidationError.new('Only PDF files are supported', { file_name: file_name })
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
if buffer.bytesize > MAX_UPLOAD_BYTES
|
|
864
|
+
raise ValidationError.new(
|
|
865
|
+
'File size exceeds maximum allowed (25MB)',
|
|
866
|
+
{ file_size: buffer.bytesize, max_size: MAX_UPLOAD_BYTES }
|
|
867
|
+
)
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def template_body(signers_or_payload, options = {})
|
|
872
|
+
body =
|
|
873
|
+
if signers_or_payload.is_a?(Hash)
|
|
874
|
+
signers_or_payload.merge(options)
|
|
875
|
+
else
|
|
876
|
+
options.merge(signers: signers_or_payload)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
unless body[:signers] || body['signers']
|
|
880
|
+
raise ValidationError.new('signers are required')
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
body_params(body)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def tag_names(tags, allow_empty: false)
|
|
887
|
+
unless tags.is_a?(Array)
|
|
888
|
+
raise ValidationError.new('Tags must be an Array')
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
if tags.empty? && !allow_empty
|
|
892
|
+
raise ValidationError.new('Tags must be a non-empty Array')
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
tags.each do |tag|
|
|
896
|
+
next unless tag.to_s.strip.empty?
|
|
897
|
+
|
|
898
|
+
raise ValidationError.new('Tag names cannot be empty')
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
tags
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def artifact_type(artifact_name)
|
|
905
|
+
value = require_id(artifact_name, 'Artifact name').to_s
|
|
906
|
+
return value if ARTIFACT_TYPES.include?(value)
|
|
907
|
+
|
|
908
|
+
raise ValidationError.new('Invalid artifact type', { artifact_name: artifact_name })
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
end
|