anvil-ruby 0.1.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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anvil
4
+ class PDF < Resources::Base
5
+ attr_reader :raw_data
6
+
7
+ def initialize(raw_data, attributes = {}, client: nil)
8
+ super(attributes, client: client)
9
+ @raw_data = raw_data
10
+ end
11
+
12
+ # Save the PDF to a file
13
+ def save_as(filename, mode = 'wb')
14
+ raise FileError, 'No PDF data to save' unless raw_data
15
+
16
+ File.open(filename, mode) do |file|
17
+ file.write(raw_data)
18
+ end
19
+ filename
20
+ end
21
+
22
+ # Save and raise on error
23
+ def save_as!(filename, mode = 'wb')
24
+ save_as(filename, mode)
25
+ rescue StandardError => e
26
+ raise FileError, "Failed to save PDF: #{e.message}"
27
+ end
28
+
29
+ # Get the PDF as a base64 encoded string
30
+ def to_base64
31
+ return nil unless raw_data
32
+
33
+ Base64.strict_encode64(raw_data)
34
+ end
35
+
36
+ # Get the size in bytes
37
+ def size
38
+ raw_data&.bytesize || 0
39
+ end
40
+
41
+ def size_human
42
+ size_in_bytes = size
43
+ return '0 B' if size_in_bytes.zero?
44
+
45
+ units = %w[B KB MB GB]
46
+ exp = (Math.log(size_in_bytes) / Math.log(1024)).to_i
47
+ exp = units.size - 1 if exp >= units.size
48
+
49
+ format('%.2f %s', size_in_bytes.to_f / (1024**exp), units[exp])
50
+ end
51
+
52
+ class << self
53
+ # Fill a PDF template with data
54
+ #
55
+ # @param template_id [String] The PDF template ID
56
+ # @param data [Hash] The data to fill the PDF with
57
+ # @param options [Hash] Additional options
58
+ # @option options [String] :title Optional document title
59
+ # @option options [String] :font_family Font family (default: "Noto Sans")
60
+ # @option options [Integer] :font_size Font size (default: 10)
61
+ # @option options [String] :text_color Text color (default: "#333333")
62
+ # @option options [Boolean] :use_interactive_fields Use interactive form fields
63
+ # @option options [String] :api_key Optional API key override
64
+ # @return [PDF] The filled PDF
65
+ def fill(template_id:, data:, **options)
66
+ api_key = options.delete(:api_key)
67
+ client = api_key ? Client.new(api_key: api_key) : self.client
68
+
69
+ payload = build_fill_payload(data, options)
70
+ path = "/fill/#{template_id}.pdf"
71
+
72
+ response = client.post(path, payload)
73
+
74
+ raise APIError, "Expected PDF response but got: #{response.content_type}" unless response.binary?
75
+
76
+ new(response.raw_body, { template_id: template_id }, client: client)
77
+ end
78
+
79
+ # Generate a PDF from HTML or Markdown
80
+ #
81
+ # @param type [Symbol, String] :html or :markdown
82
+ # @param data [Hash, Array] Content data
83
+ # @param options [Hash] Additional options
84
+ # @option options [String] :title Document title
85
+ # @option options [Hash] :page Page configuration
86
+ # @option options [String] :api_key Optional API key override
87
+ # @return [PDF] The generated PDF
88
+ def generate(data:, type: :markdown, **options)
89
+ api_key = options.delete(:api_key)
90
+ client = api_key ? Client.new(api_key: api_key) : self.client
91
+
92
+ type = type.to_s.downcase
93
+ unless %w[html markdown].include?(type)
94
+ raise ArgumentError, "Type must be :html or :markdown, got #{type.inspect}"
95
+ end
96
+
97
+ payload = build_generate_payload(type, data, options)
98
+ path = '/generate-pdf'
99
+
100
+ response = client.post(path, payload)
101
+
102
+ raise APIError, "Expected PDF response but got: #{response.content_type}" unless response.binary?
103
+
104
+ new(response.raw_body, { type: type }, client: client)
105
+ end
106
+
107
+ # Convenience methods for specific generation types
108
+ def generate_from_html(html:, css: nil, **options)
109
+ data = { html: html }
110
+ data[:css] = css if css
111
+ generate(type: :html, data: data, **options)
112
+ end
113
+
114
+ def generate_from_markdown(content, **options)
115
+ data = if content.is_a?(String)
116
+ [{ content: content }]
117
+ elsif content.is_a?(Array)
118
+ content
119
+ else
120
+ raise ArgumentError, 'Markdown content must be a string or array'
121
+ end
122
+
123
+ generate(type: :markdown, data: data, **options)
124
+ end
125
+
126
+ private
127
+
128
+ def build_fill_payload(data, options)
129
+ payload = { data: data }
130
+
131
+ # Add optional parameters if provided
132
+ payload[:title] = options[:title] if options[:title]
133
+ payload[:fontSize] = options[:font_size] if options[:font_size]
134
+ payload[:fontFamily] = options[:font_family] if options[:font_family]
135
+ payload[:textColor] = options[:text_color] if options[:text_color]
136
+ payload[:useInteractiveFields] = options[:use_interactive_fields] if options.key?(:use_interactive_fields)
137
+
138
+ payload
139
+ end
140
+
141
+ def build_generate_payload(type, data, options)
142
+ payload = {
143
+ type: type,
144
+ data: data
145
+ }
146
+
147
+ # Add optional parameters
148
+ payload[:title] = options[:title] if options[:title]
149
+
150
+ # Page configuration
151
+ payload[:page] = build_page_config(options[:page]) if options[:page]
152
+
153
+ payload
154
+ end
155
+
156
+ def self.build_page_config(page)
157
+ return nil unless page
158
+
159
+ config = {}
160
+ config[:width] = page[:width] if page[:width]
161
+ config[:height] = page[:height] if page[:height]
162
+ config[:marginTop] = page[:margin_top] if page[:margin_top]
163
+ config[:marginBottom] = page[:margin_bottom] if page[:margin_bottom]
164
+ config[:marginLeft] = page[:margin_left] if page[:margin_left]
165
+ config[:marginRight] = page[:margin_right] if page[:margin_right]
166
+ config[:pageCount] = page[:page_count] if page.key?(:page_count)
167
+ config
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anvil
4
+ class Signature < Resources::Base
5
+ # Etch packet statuses
6
+ STATUSES = %w[draft sent partial_complete complete].freeze
7
+
8
+ def id
9
+ attributes[:eid] || attributes[:id]
10
+ end
11
+
12
+ def eid
13
+ attributes[:eid]
14
+ end
15
+
16
+ def status
17
+ attributes[:status]
18
+ end
19
+
20
+ def name
21
+ attributes[:name]
22
+ end
23
+
24
+ # Check packet status
25
+ def draft?
26
+ status == 'draft'
27
+ end
28
+
29
+ def sent?
30
+ status == 'sent'
31
+ end
32
+
33
+ def partially_complete?
34
+ status == 'partial_complete'
35
+ end
36
+
37
+ def complete?
38
+ status == 'complete'
39
+ end
40
+
41
+ def completed?
42
+ complete?
43
+ end
44
+
45
+ # Has the packet been sent to signers?
46
+ def in_progress?
47
+ sent? || partially_complete?
48
+ end
49
+
50
+ # Get signing URL for a specific signer
51
+ def signing_url(signer_id:, client_user_id: nil)
52
+ self.class.generate_signing_url(
53
+ packet_eid: eid,
54
+ signer_eid: signer_id,
55
+ client_user_id: client_user_id,
56
+ client: client
57
+ )
58
+ end
59
+
60
+ # Get all signers
61
+ def signers
62
+ Array(attributes[:signers]).map do |signer|
63
+ SignatureSigner.new(signer, packet: self)
64
+ end
65
+ end
66
+
67
+ # Get documents
68
+ def documents
69
+ Array(attributes[:documents])
70
+ end
71
+
72
+ # Reload from API
73
+ def reload!
74
+ refreshed = self.class.find(eid, client: client)
75
+ @attributes = refreshed.attributes
76
+ self
77
+ end
78
+
79
+ # Update the signature packet
80
+ #
81
+ # @param options [Hash] Fields to update (name, signers, email_subject, email_body)
82
+ # @return [self] The updated signature packet
83
+ def update(**options)
84
+ payload = build_update_payload(options)
85
+
86
+ data = client.graphql(self.class.send(:update_packet_mutation), variables: { eid: eid, input: payload })
87
+ raise APIError, "Failed to update signature packet: #{eid}" unless data && data[:updateEtchPacket]
88
+
89
+ @attributes = symbolize_keys(data[:updateEtchPacket])
90
+ self
91
+ end
92
+
93
+ # Send a draft packet to signers
94
+ #
95
+ # @return [self] The sent signature packet
96
+ def send!
97
+ data = client.graphql(self.class.send(:send_packet_mutation), variables: { eid: eid })
98
+ raise APIError, "Failed to send signature packet: #{eid}" unless data && data[:sendEtchPacket]
99
+
100
+ @attributes = symbolize_keys(data[:sendEtchPacket])
101
+ self
102
+ end
103
+
104
+ # Delete the signature packet
105
+ #
106
+ # @return [Boolean] true if deleted
107
+ def delete!
108
+ data = client.graphql(self.class.send(:remove_packet_mutation), variables: { eid: eid })
109
+ raise APIError, "Failed to delete signature packet: #{eid}" unless data
110
+
111
+ true
112
+ end
113
+
114
+ # Skip a signer in the signature flow
115
+ #
116
+ # @param signer_eid [String] The EID of the signer to skip
117
+ # @return [self] The updated signature packet
118
+ def skip_signer(signer_eid)
119
+ data = client.graphql(self.class.send(:skip_signer_mutation),
120
+ variables: { signerEid: signer_eid, packetEid: eid })
121
+ raise APIError, "Failed to skip signer: #{signer_eid}" unless data
122
+
123
+ reload!
124
+ end
125
+
126
+ # Send a reminder notification to a signer
127
+ #
128
+ # @param signer_eid [String] The EID of the signer to notify
129
+ # @return [Boolean] true if notification sent
130
+ def notify_signer(signer_eid)
131
+ data = client.graphql(self.class.send(:notify_signer_mutation),
132
+ variables: { signerEid: signer_eid, packetEid: eid })
133
+ raise APIError, "Failed to notify signer: #{signer_eid}" unless data
134
+
135
+ true
136
+ end
137
+
138
+ # Void signed documents
139
+ #
140
+ # @return [Boolean] true if voided
141
+ def void!
142
+ data = client.graphql(
143
+ self.class.send(:void_document_group_mutation),
144
+ variables: { eid: eid }
145
+ )
146
+ raise APIError, "Failed to void signature packet: #{eid}" unless data
147
+
148
+ reload!
149
+ true
150
+ end
151
+
152
+ # Expire all active signing sessions
153
+ #
154
+ # @return [Boolean] true if tokens expired
155
+ def expire_tokens!
156
+ data = client.graphql(
157
+ self.class.send(:expire_signer_tokens_mutation),
158
+ variables: { eid: eid }
159
+ )
160
+ raise APIError, "Failed to expire signer tokens: #{eid}" unless data
161
+
162
+ true
163
+ end
164
+
165
+ private
166
+
167
+ def build_update_payload(options)
168
+ payload = {}
169
+ payload[:name] = options[:name] if options.key?(:name)
170
+ payload[:signers] = self.class.send(:build_signers_payload, options[:signers]) if options[:signers]
171
+ payload[:signatureEmailSubject] = options[:email_subject] if options[:email_subject]
172
+ payload[:signatureEmailBody] = options[:email_body] if options[:email_body]
173
+ payload
174
+ end
175
+
176
+ class << self
177
+ # Create a new signature packet
178
+ #
179
+ # @param name [String] Name of the packet
180
+ # @param signers [Array<Hash>] Array of signer information
181
+ # @param files [Array<Hash>] Array of files to sign
182
+ # @param options [Hash] Additional options
183
+ # @return [Signature] The created signature packet
184
+ def create(name:, signers:, files: nil, **options)
185
+ api_key = options.delete(:api_key)
186
+ client = api_key ? Client.new(api_key: api_key) : self.client
187
+
188
+ payload = build_create_payload(name, signers, files, options)
189
+
190
+ # Use full GraphQL endpoint URL
191
+ response = client.post(client.config.graphql_url, {
192
+ query: create_packet_mutation,
193
+ variables: { input: payload }
194
+ })
195
+
196
+ data = response.data
197
+ unless data[:data] && data[:data][:createEtchPacket]
198
+ raise APIError, "Failed to create signature packet: #{data[:errors]}"
199
+ end
200
+
201
+ new(data[:data][:createEtchPacket], client: client)
202
+ end
203
+
204
+ # Find a signature packet by ID
205
+ def find(packet_eid, client: nil)
206
+ client ||= self.client
207
+
208
+ response = client.post(client.config.graphql_url, {
209
+ query: find_packet_query,
210
+ variables: { eid: packet_eid }
211
+ })
212
+
213
+ data = response.data
214
+ raise NotFoundError, "Signature packet not found: #{packet_eid}" unless data[:data] && data[:data][:etchPacket]
215
+
216
+ new(data[:data][:etchPacket], client: client)
217
+ end
218
+
219
+ # List all signature packets
220
+ def list(limit: 10, offset: 0, status: nil, client: nil)
221
+ client ||= self.client
222
+
223
+ variables = { limit: limit, offset: offset }
224
+ variables[:status] = status if status
225
+
226
+ response = client.post(client.config.graphql_url, {
227
+ query: list_packets_query,
228
+ variables: variables
229
+ })
230
+
231
+ data = response.data
232
+ if data[:data] && data[:data][:etchPackets]
233
+ data[:data][:etchPackets].map { |packet| new(packet, client: client) }
234
+ else
235
+ []
236
+ end
237
+ end
238
+
239
+ # Generate a signing URL for a signer
240
+ def generate_signing_url(packet_eid:, signer_eid:, client_user_id: nil, client: nil)
241
+ client ||= self.client
242
+
243
+ payload = {
244
+ packetEid: packet_eid,
245
+ signerEid: signer_eid
246
+ }
247
+ payload[:clientUserId] = client_user_id if client_user_id
248
+
249
+ response = client.post(client.config.graphql_url, {
250
+ query: generate_url_mutation,
251
+ variables: { input: payload }
252
+ })
253
+
254
+ data = response.data
255
+ raise APIError, 'Failed to generate signing URL' unless data[:data] && data[:data][:generateEtchSignURL]
256
+
257
+ data[:data][:generateEtchSignURL][:url]
258
+ end
259
+
260
+ private
261
+
262
+ def build_create_payload(name, signers, files, options)
263
+ payload = {
264
+ name: name,
265
+ signers: build_signers_payload(signers)
266
+ }
267
+
268
+ payload[:files] = build_files_payload(files) if files
269
+ payload[:isDraft] = options[:is_draft] if options.key?(:is_draft)
270
+ payload[:webhookURL] = options[:webhook_url] if options[:webhook_url]
271
+ payload[:signatureEmailSubject] = options[:email_subject] if options[:email_subject]
272
+ payload[:signatureEmailBody] = options[:email_body] if options[:email_body]
273
+
274
+ payload
275
+ end
276
+
277
+ def build_signers_payload(signers)
278
+ signers.map do |signer|
279
+ {
280
+ name: signer[:name],
281
+ email: signer[:email],
282
+ role: signer[:role] || 'signer',
283
+ signerType: signer[:signer_type] || 'email'
284
+ }.compact
285
+ end
286
+ end
287
+
288
+ def build_files_payload(files)
289
+ files.map do |file|
290
+ if file[:type] == :pdf && file[:id]
291
+ # Template ID should be castEid
292
+ {
293
+ id: 'file1', # File identifier
294
+ castEid: file[:id] # Template ID
295
+ }
296
+ elsif file[:type] == :upload && file[:data]
297
+ {
298
+ type: 'upload',
299
+ data: Base64.strict_encode64(file[:data]),
300
+ filename: file[:filename] || 'document.pdf'
301
+ }
302
+ else
303
+ file
304
+ end
305
+ end
306
+ end
307
+
308
+ # GraphQL queries and mutations (simplified versions)
309
+ def create_packet_mutation
310
+ <<~GRAPHQL
311
+ mutation CreateEtchPacket($input: JSON) {
312
+ createEtchPacket(variables: $input) {
313
+ eid
314
+ name
315
+ status
316
+ createdAt
317
+ signers {
318
+ eid
319
+ name
320
+ email
321
+ status
322
+ }
323
+ }
324
+ }
325
+ GRAPHQL
326
+ end
327
+
328
+ def find_packet_query
329
+ <<~GRAPHQL
330
+ query GetEtchPacket($eid: String!) {
331
+ etchPacket(eid: $eid) {
332
+ eid
333
+ name
334
+ status
335
+ createdAt
336
+ completedAt
337
+ signers {
338
+ eid
339
+ name
340
+ email
341
+ status
342
+ completedAt
343
+ }
344
+ documents {
345
+ eid
346
+ name
347
+ type
348
+ }
349
+ }
350
+ }
351
+ GRAPHQL
352
+ end
353
+
354
+ def list_packets_query
355
+ <<~GRAPHQL
356
+ query ListEtchPackets($limit: Int, $offset: Int, $status: String) {
357
+ etchPackets(limit: $limit, offset: $offset, status: $status) {
358
+ eid
359
+ name
360
+ status
361
+ createdAt
362
+ completedAt
363
+ }
364
+ }
365
+ GRAPHQL
366
+ end
367
+
368
+ def generate_url_mutation
369
+ <<~GRAPHQL
370
+ mutation GenerateEtchSignURL($input: GenerateEtchSignURLInput!) {
371
+ generateEtchSignURL(input: $input) {
372
+ url
373
+ }
374
+ }
375
+ GRAPHQL
376
+ end
377
+
378
+ def update_packet_mutation
379
+ <<~GRAPHQL
380
+ mutation UpdateEtchPacket($eid: String!, $input: JSON) {
381
+ updateEtchPacket(eid: $eid, variables: $input) {
382
+ eid
383
+ name
384
+ status
385
+ createdAt
386
+ signers {
387
+ eid
388
+ name
389
+ email
390
+ status
391
+ }
392
+ }
393
+ }
394
+ GRAPHQL
395
+ end
396
+
397
+ def send_packet_mutation
398
+ <<~GRAPHQL
399
+ mutation SendEtchPacket($eid: String!) {
400
+ sendEtchPacket(eid: $eid) {
401
+ eid
402
+ name
403
+ status
404
+ createdAt
405
+ signers {
406
+ eid
407
+ name
408
+ email
409
+ status
410
+ }
411
+ }
412
+ }
413
+ GRAPHQL
414
+ end
415
+
416
+ def remove_packet_mutation
417
+ <<~GRAPHQL
418
+ mutation RemoveEtchPacket($eid: String!) {
419
+ removeEtchPacket(eid: $eid)
420
+ }
421
+ GRAPHQL
422
+ end
423
+
424
+ def skip_signer_mutation
425
+ <<~GRAPHQL
426
+ mutation SkipSigner($signerEid: String!, $packetEid: String!) {
427
+ skipSigner(signerEid: $signerEid, packetEid: $packetEid)
428
+ }
429
+ GRAPHQL
430
+ end
431
+
432
+ def notify_signer_mutation
433
+ <<~GRAPHQL
434
+ mutation NotifySigner($signerEid: String!, $packetEid: String!) {
435
+ notifySigner(signerEid: $signerEid, packetEid: $packetEid)
436
+ }
437
+ GRAPHQL
438
+ end
439
+
440
+ def void_document_group_mutation
441
+ <<~GRAPHQL
442
+ mutation VoidDocumentGroup($eid: String!) {
443
+ voidDocumentGroup(eid: $eid)
444
+ }
445
+ GRAPHQL
446
+ end
447
+
448
+ def expire_signer_tokens_mutation
449
+ <<~GRAPHQL
450
+ mutation ExpireSignerTokens($eid: String!) {
451
+ expireSignerTokens(eid: $eid)
452
+ }
453
+ GRAPHQL
454
+ end
455
+ end
456
+ end
457
+
458
+ # Helper class for signature signers
459
+ class SignatureSigner < Resources::Base
460
+ attr_reader :packet
461
+
462
+ def initialize(attributes, packet: nil)
463
+ super(attributes)
464
+ @packet = packet
465
+ end
466
+
467
+ def eid
468
+ attributes[:eid]
469
+ end
470
+
471
+ def name
472
+ attributes[:name]
473
+ end
474
+
475
+ def email
476
+ attributes[:email]
477
+ end
478
+
479
+ def status
480
+ attributes[:status]
481
+ end
482
+
483
+ def complete?
484
+ status == 'complete'
485
+ end
486
+
487
+ def completed_at
488
+ return unless attributes[:completed_at]
489
+
490
+ Time.parse(attributes[:completed_at])
491
+ end
492
+
493
+ # Get signing URL for this signer
494
+ def signing_url(client_user_id: nil)
495
+ return nil unless packet
496
+
497
+ packet.signing_url(
498
+ signer_id: eid,
499
+ client_user_id: client_user_id
500
+ )
501
+ end
502
+
503
+ # Skip this signer
504
+ def skip!
505
+ raise Error, 'No packet associated with this signer' unless packet
506
+
507
+ packet.skip_signer(eid)
508
+ end
509
+
510
+ # Send a reminder to this signer
511
+ def send_reminder!
512
+ raise Error, 'No packet associated with this signer' unless packet
513
+
514
+ packet.notify_signer(eid)
515
+ end
516
+ end
517
+ end