From 51ced5ef1e47d4168bcba00fdffa748936d89e1b Mon Sep 17 00:00:00 2001 From: Sebastian Noe Date: Thu, 7 May 2026 16:34:44 +0200 Subject: [PATCH] feat: add template creation and document management API - Add POST /api/templates/pdf for creating templates from PDF files - Supports base64 content and URL file sources - Supports external_id upsert, folder assignment, explicit field coords - Supports embedded {{field;type=x;role=y}} text tag extraction - Add PUT /api/templates/:id/documents for add/replace/remove operations - Add Api::DecodeDocumentFile utility for shared base64/URL handling - Add comprehensive API documentation in docs/API.md - Add routes for new API endpoints --- .../api/templates_documents_controller.rb | 93 ++++ .../api/templates_pdf_controller.rb | 95 ++++ config/routes.rb | 5 + docs/API.md | 451 ++++++++++++++++++ lib/api/decode_document_file.rb | 53 ++ 5 files changed, 697 insertions(+) create mode 100644 app/controllers/api/templates_documents_controller.rb create mode 100644 app/controllers/api/templates_pdf_controller.rb create mode 100644 docs/API.md create mode 100644 lib/api/decode_document_file.rb diff --git a/app/controllers/api/templates_documents_controller.rb b/app/controllers/api/templates_documents_controller.rb new file mode 100644 index 00000000..bca73fa2 --- /dev/null +++ b/app/controllers/api/templates_documents_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Api + class TemplatesDocumentsController < ApiBaseController + load_and_authorize_resource :template + + def update + authorize!(:update, @template) + + Array.wrap(params[:documents]).each do |doc_params| + if doc_params[:remove].in?([true, 'true']) + remove_document(doc_params) + elsif doc_params[:replace].in?([true, 'true']) + replace_document(doc_params) + else + add_document(doc_params) + end + end + + @template.save! + + SearchEntries.enqueue_reindex(@template) + WebhookUrls.enqueue_events(@template, 'template.updated') + + render json: Templates::SerializeForApi.call(@template) + end + + private + + def remove_document(doc_params) + position = doc_params[:position]&.to_i + name = doc_params[:name] + + removed_schema = if position + @template.schema.delete_at(position) + elsif name + @template.schema.detect { |s| s['name'] == name }&.tap { |s| @template.schema.delete(s) } + end + + return unless removed_schema + + @template.fields.reject! do |field| + field['areas']&.any? { |a| a['attachment_uuid'] == removed_schema['attachment_uuid'] } + end + end + + def replace_document(doc_params) + position = doc_params[:position]&.to_i || 0 + + file = Api::DecodeDocumentFile.call(doc_params[:file], name: doc_params[:name]) + documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true) + document = documents.first + + return unless document + + old_schema = @template.schema[position] + new_schema = { 'attachment_uuid' => document.uuid, 'name' => document.filename.base } + + if old_schema + new_doc_has_fields = @template.fields.any? { |f| f['areas']&.any? { |a| a['attachment_uuid'] == document.uuid } } + + unless new_doc_has_fields + @template.fields.each do |field| + field['areas']&.each do |area| + area['attachment_uuid'] = document.uuid if area['attachment_uuid'] == old_schema['attachment_uuid'] + end + end + end + + @template.schema[position] = new_schema + else + @template.schema << new_schema + end + end + + def add_document(doc_params) + file = Api::DecodeDocumentFile.call(doc_params[:file], name: doc_params[:name]) + documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true) + document = documents.first + + return unless document + + new_schema = { 'attachment_uuid' => document.uuid, 'name' => doc_params[:name] || document.filename.base } + position = doc_params[:position]&.to_i + + if position && position < @template.schema.size + @template.schema.insert(position, new_schema) + else + @template.schema << new_schema + end + end + end +end diff --git a/app/controllers/api/templates_pdf_controller.rb b/app/controllers/api/templates_pdf_controller.rb new file mode 100644 index 00000000..27c9dad2 --- /dev/null +++ b/app/controllers/api/templates_pdf_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Api + class TemplatesPdfController < ApiBaseController + def create + authorize!(:create, Template) + + @template = if params[:external_id].present? + current_account.templates.find_or_initialize_by(external_id: params[:external_id]) + else + current_account.templates.new + end + + @template.assign_attributes( + author: current_user, + source: :api, + name: params[:name].presence || extract_default_name, + external_id: params[:external_id] + ) + + if params[:folder_name].present? + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) + end + + @template.save! + + process_documents + + Templates.maybe_assign_access(@template) + + @template.save! + + SearchEntries.enqueue_reindex(@template) + WebhookUrls.enqueue_events(@template, @template.previously_new_record? ? 'template.created' : 'template.updated') + + render json: Templates::SerializeForApi.call(@template) + end + + private + + def process_documents + Array.wrap(params[:documents]).each do |doc_params| + file = Api::DecodeDocumentFile.call(doc_params[:file], name: doc_params[:name] || 'document.pdf') + documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true) + document = documents.first + + next unless document + + schema_entry = { 'attachment_uuid' => document.uuid, 'name' => doc_params[:name] || document.filename.base } + @template.schema << schema_entry + + apply_explicit_fields(document, doc_params[:fields]) if doc_params[:fields].present? + end + end + + def apply_explicit_fields(document, fields_params) + Array.wrap(fields_params).each do |field_params| + role = field_params[:role] || 'First Party' + + submitter = @template.submitters.find { |s| s['name'] == role } + unless submitter + submitter = { 'name' => role, 'uuid' => SecureRandom.uuid } + @template.submitters << submitter + end + + areas = Array.wrap(field_params[:areas]).map do |area| + { + 'attachment_uuid' => document.uuid, + 'x' => area[:x].to_f, + 'y' => area[:y].to_f, + 'w' => area[:w].to_f, + 'h' => area[:h].to_f, + 'page' => (area[:page].to_i - 1) + } + end + + field = { + 'uuid' => SecureRandom.uuid, + 'submitter_uuid' => submitter['uuid'], + 'name' => field_params[:name] || field_params[:type]&.humanize || 'Field', + 'type' => field_params[:type] || 'text', + 'required' => field_params[:required] != false, + 'areas' => areas + } + + @template.fields << field + end + end + + def extract_default_name + first_doc = Array.wrap(params[:documents]).first + first_doc&.dig(:name) || 'Untitled' + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 6b569394..0f482850 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,10 @@ Rails.application.routes.draw do resources :templates, only: %i[update show index destroy] do resources :clone, only: %i[create], controller: 'templates_clone' resources :submissions, only: %i[index create] + resource :documents, only: %i[update], controller: 'templates_documents' + end + scope :templates, as: :templates do + post 'pdf', to: 'templates_pdf#create', as: :pdf end resources :tools, only: %i[] do post :merge, on: :collection @@ -192,6 +196,7 @@ Rails.application.routes.draw do resources :integration_users, only: %i[index], path: 'users/:status', controller: 'users', defaults: { status: :integration } resource :personalization, only: %i[show create], controller: 'personalization_settings' + resource :account_logo, only: %i[create destroy], controller: 'account_logo' resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do post :resend diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..efe9e579 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,451 @@ +# DocuSeal API Reference — Extended Endpoints + +This documents the additional API endpoints implemented in this fork beyond the base DocuSeal OSS API. + +Authentication: All endpoints require an `X-Auth-Token` header with a valid API token. + +Base URL: `http://your-host/api` + +--- + +## POST /api/templates/pdf + +Create a fillable document template from one or more PDF files. + +PDF files may contain embedded text field tags using the `{{Field Name;role=Signer1;type=date}}` syntax. Fields are automatically extracted from these tags. Alternatively (or additionally), you can specify explicit field positions using the `fields` parameter with pixel-fraction coordinates. + +If a template with the given `external_id` already exists, it will be updated with the new documents. + +### Request + +``` +POST /api/templates/pdf +Content-Type: application/json +X-Auth-Token: YOUR_API_KEY +``` + +### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | String | No | Template name. Defaults to the first document's name. | +| `external_id` | String | No | Your application-specific unique key. If a template with this ID exists, it will be updated instead of creating a new one. | +| `folder_name` | String | No | Name of the folder to place the template in. Created automatically if it doesn't exist. | +| `documents` | Array | **Yes** | Array of PDF document objects. | + +#### `documents[]` object + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | String | **Yes** | Name of the document. | +| `file` | String | **Yes** | Base64-encoded PDF content, OR a publicly accessible URL to download the PDF from. | +| `fields` | Array | No | Explicit field definitions with coordinates. Optional if you use `{{...}}` text tags in the PDF. | + +#### `documents[].fields[]` object + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | String | No | Field name displayed in the signing form. | +| `type` | String | No | Field type. Default: `text`. See field types below. | +| `role` | String | No | Role name of the signer who fills this field. Default: `First Party`. | +| `required` | Boolean | No | Whether the field is required. Default: `true`. | +| `areas` | Array | No | Positioning coordinates on the document page. | + +**Field types:** `text`, `signature`, `initials`, `date`, `number`, `image`, `checkbox`, `multiple`, `file`, `radio`, `select`, `cells`, `stamp`, `payment`, `phone` + +#### `documents[].fields[].areas[]` object + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `x` | Number | **Yes** | X-coordinate as a fraction of page width (0.0 to 1.0). | +| `y` | Number | **Yes** | Y-coordinate as a fraction of page height (0.0 to 1.0). | +| `w` | Number | **Yes** | Width as a fraction of page width (0.0 to 1.0). | +| `h` | Number | **Yes** | Height as a fraction of page height (0.0 to 1.0). | +| `page` | Integer | **Yes** | Page number, **starting from 1**. | + +### Example Request + +```bash +curl -X POST https://your-host/api/templates/pdf \ + -H "X-Auth-Token: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Rental Agreement", + "external_id": "rental-v2", + "folder_name": "Contracts", + "documents": [ + { + "name": "rental-agreement", + "file": "JVBERi0xLjQK...", + "fields": [ + { + "name": "Tenant Name", + "type": "text", + "role": "Tenant", + "required": true, + "areas": [{"x": 0.1, "y": 0.3, "w": 0.35, "h": 0.03, "page": 1}] + }, + { + "name": "Tenant Signature", + "type": "signature", + "role": "Tenant", + "areas": [{"x": 0.1, "y": 0.85, "w": 0.3, "h": 0.06, "page": 2}] + }, + { + "name": "Landlord Signature", + "type": "signature", + "role": "Landlord", + "areas": [{"x": 0.55, "y": 0.85, "w": 0.3, "h": 0.06, "page": 2}] + } + ] + } + ] + }' +``` + +### Example with URL + +```bash +curl -X POST https://your-host/api/templates/pdf \ + -H "X-Auth-Token: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "NDA Template", + "documents": [ + { + "name": "nda", + "file": "https://example.com/documents/nda-template.pdf" + } + ] + }' +``` + +### Example with Text Tags (no explicit fields) + +If your PDF contains text like `{{Full Name;type=text;role=Employee}}` and `{{Signature;type=signature;role=Employee}}`, fields are extracted automatically: + +```bash +curl -X POST https://your-host/api/templates/pdf \ + -H "X-Auth-Token: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Employment Contract", + "external_id": "emp-contract-v1", + "documents": [ + { + "name": "contract", + "file": "https://example.com/tagged-contract.pdf" + } + ] + }' +``` + +### Response + +Returns the full template object: + +```json +{ + "id": 42, + "slug": "ZQpF222rFBv71q", + "name": "Rental Agreement", + "schema": [ + { + "name": "rental-agreement", + "attachment_uuid": "09a8bc73-a7a9-4fd9-8173-95752bdf0af5" + } + ], + "fields": [ + { + "uuid": "a06c49f6-4b20-4442-ac7f-c1040d2cf1ac", + "submitter_uuid": "93ba628c-5913-4456-a1e9-1a81ad7444b3", + "name": "Tenant Name", + "type": "text", + "required": true, + "areas": [ + { + "attachment_uuid": "09a8bc73-a7a9-4fd9-8173-95752bdf0af5", + "x": 0.1, + "y": 0.3, + "w": 0.35, + "h": 0.03, + "page": 0 + } + ] + } + ], + "submitters": [ + { + "name": "Tenant", + "uuid": "93ba628c-5913-4456-a1e9-1a81ad7444b3" + }, + { + "name": "Landlord", + "uuid": "b7de5f12-3c89-4a67-b890-1234567890ab" + } + ], + "author_id": 1, + "author": { + "id": 1, + "first_name": "John", + "last_name": "Doe", + "email": "admin@example.com" + }, + "source": "api", + "external_id": "rental-v2", + "folder_id": 5, + "folder_name": "Contracts", + "archived_at": null, + "created_at": "2026-05-07T10:30:00.000Z", + "updated_at": "2026-05-07T10:30:00.000Z", + "documents": [ + { + "id": 101, + "uuid": "09a8bc73-a7a9-4fd9-8173-95752bdf0af5", + "url": "https://your-host/blobs/proxy/abc123/rental-agreement.pdf", + "preview_image_url": "https://your-host/blobs/proxy/def456/0.png", + "filename": "rental-agreement.pdf" + } + ] +} +``` + +### Notes + +- Page numbers in the request are **1-indexed** (page 1 = first page). In the response, they are **0-indexed** (page 0 = first page). This matches the DocuSeal Pro API behavior. +- When `external_id` is provided and a template with that ID exists, the template is updated (upsert behavior). The webhook event will be `template.updated` instead of `template.created`. +- Fields extracted from PDF text tags use the syntax: `{{Field Name;role=RoleName;type=fieldtype;required=false}}`. Attributes are separated by semicolons. +- Both base64 content and HTTP(S) URLs are supported for the `file` parameter. + +### Errors + +| Status | Condition | +|--------|-----------| +| 401 | Missing or invalid `X-Auth-Token` | +| 403 | Token valid but user lacks permission to create templates | +| 422 | Invalid parameters (missing documents, invalid JSON) | + +--- + +## PUT /api/templates/{id}/documents + +Add, replace, or remove documents in an existing template. + +### Request + +``` +PUT /api/templates/{id}/documents +Content-Type: application/json +X-Auth-Token: YOUR_API_KEY +``` + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | Integer | **Yes** | The template ID. | + +### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `documents` | Array | **Yes** | Array of document operation objects. | + +#### `documents[]` object + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | String | No | Document name. | +| `file` | String | No | Base64-encoded PDF content or URL. Required unless `remove` is true. | +| `position` | Integer | No | Zero-indexed position in the template's document list. For add: where to insert (default: append). For replace/remove: which document to target. | +| `replace` | Boolean | No | Set to `true` to replace the document at `position` with the new file. Existing field positions are transferred to the new document if the new document has no auto-detected fields. Default: `false`. | +| `remove` | Boolean | No | Set to `true` to remove the document at `position` or matching `name`. Default: `false`. | + +### Operations + +#### Add a document (default) + +Adds a new document to the template at the specified position (or appends to the end). + +```json +{ + "documents": [ + { + "name": "Appendix A", + "file": "JVBERi0xLjQK...", + "position": 1 + } + ] +} +``` + +#### Replace a document + +Replaces the document at `position` with a new file. If the new document doesn't contain any auto-detected fields, existing fields are remapped to the new document (preserving their coordinates). + +```json +{ + "documents": [ + { + "file": "JVBERi0xLjQK...", + "position": 0, + "replace": true + } + ] +} +``` + +#### Remove a document + +Removes the document at `position` or matching `name`. All fields associated with the removed document are also deleted. + +```json +{ + "documents": [ + { + "position": 2, + "remove": true + } + ] +} +``` + +Or by name: + +```json +{ + "documents": [ + { + "name": "Appendix A", + "remove": true + } + ] +} +``` + +#### Multiple operations in one request + +You can combine add, replace, and remove operations: + +```json +{ + "documents": [ + {"position": 2, "remove": true}, + {"name": "New Main Document", "file": "JVBERi0...", "position": 0, "replace": true}, + {"name": "Addendum", "file": "https://example.com/addendum.pdf"} + ] +} +``` + +Operations are processed in array order. + +### Example Request + +```bash +curl -X PUT https://your-host/api/templates/42/documents \ + -H "X-Auth-Token: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "documents": [ + { + "name": "Updated Contract", + "file": "https://example.com/contract-v2.pdf", + "position": 0, + "replace": true + } + ] + }' +``` + +### Response + +Returns the full updated template object (same format as POST /api/templates/pdf response above). + +```json +{ + "id": 42, + "slug": "ZQpF222rFBv71q", + "name": "Rental Agreement", + "schema": [...], + "fields": [...], + "submitters": [...], + "author_id": 1, + "author": {...}, + "documents": [ + { + "id": 205, + "uuid": "new-document-uuid", + "url": "https://your-host/blobs/proxy/xyz/Updated%20Contract.pdf", + "preview_image_url": "https://your-host/blobs/proxy/abc/0.png", + "filename": "Updated Contract.pdf" + } + ], + ... +} +``` + +### Notes + +- Position is **0-indexed** (position 0 = first document). +- When replacing a document, existing field coordinates are preserved if the new document has no auto-detected fields. This is useful when replacing a template PDF with an updated version that has the same layout. +- When removing a document, all fields whose `areas` reference that document's `attachment_uuid` are also removed. +- Operations are processed sequentially in array order. Be aware that positions may shift after add/remove operations earlier in the array. + +### Errors + +| Status | Condition | +|--------|-----------| +| 401 | Missing or invalid `X-Auth-Token` | +| 403 | Token valid but user lacks permission to update this template | +| 404 | Template with given ID not found | +| 422 | Invalid parameters | + +--- + +## Text Field Tag Syntax + +When creating templates from PDF files, you can embed field tags directly in the document text. The tag format is: + +``` +{{Field Name;attribute=value;attribute=value}} +``` + +### Supported Attributes + +| Attribute | Values | Description | +|-----------|--------|-------------| +| `type` | text, signature, date, initials, number, image, checkbox, radio, select, file, stamp, phone | Field type. Default: text | +| `role` | Any string | Signer role name for multi-party documents | +| `required` | true, false | Whether the field is required. Default: true | +| `readonly` | true, false | Whether the field is read-only | +| `default` | Any string | Pre-filled default value | + +### Examples + +``` +{{Full Name;role=Employee;type=text}} +{{Signature;role=Employee;type=signature}} +{{Date of Birth;type=date;required=false}} +{{Photo ID;role=Applicant;type=image}} +{{Accept Terms;type=checkbox}} +{{Department;type=select;options=Engineering,Sales,Marketing}} +{{Rating;type=radio;option=Excellent}} +{{Rating;type=radio;option=Good}} +{{Rating;type=radio;option=Fair}} +``` + +See https://www.docuseal.com/examples/fieldtags.pdf for a complete reference of all tag formats. + +--- + +## Webhook Events + +Both endpoints trigger webhook events when configured: + +| Endpoint | Event | +|----------|-------| +| POST /api/templates/pdf (new template) | `template.created` | +| POST /api/templates/pdf (upsert existing) | `template.updated` | +| PUT /api/templates/:id/documents | `template.updated` | + +Configure webhooks in Settings > Webhooks. diff --git a/lib/api/decode_document_file.rb b/lib/api/decode_document_file.rb new file mode 100644 index 00000000..172ce2f1 --- /dev/null +++ b/lib/api/decode_document_file.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Api + module DecodeDocumentFile + module_function + + def call(file_param, name: nil) + if url?(file_param) + download_from_url(file_param, name) + else + decode_base64(file_param, name) + end + end + + def url?(str) + str.to_s.match?(%r{\Ahttps?://}) + end + + def download_from_url(url, name) + response = DownloadUtils.call(url, validate: true) + + tempfile = Tempfile.new(['document', File.extname(URI.parse(url).path).presence || '.pdf']) + tempfile.binmode + tempfile.write(response.body) + tempfile.rewind + + filename = name.presence || File.basename(URI.decode_www_form_component(URI.parse(url).path)) + + ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile, name: filename) + ) + end + + def decode_base64(data, name) + decoded = Base64.decode64(data) + + tempfile = Tempfile.new(['document', '.pdf']) + tempfile.binmode + tempfile.write(decoded) + tempfile.rewind + + filename = name.presence || 'document.pdf' + + ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile, name: filename) + ) + end + end +end