diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbd6dee4..a12c4d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,16 +35,13 @@ jobs: defaults: run: working-directory: packages/py-sdk - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.9' - name: Install dependencies run: pip install -e ".[dev]" @@ -58,89 +55,35 @@ jobs: defaults: run: working-directory: packages/go-sdk - strategy: - matrix: - go-version: ['1.21', '1.22'] steps: - uses: actions/checkout@v4 - - name: Set up Go ${{ matrix.go-version }} + - name: Set up Go 1.21 uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go-version }} + go-version: '1.21' - name: Download dependencies - run: go mod download + run: go mod tidy - name: Run tests run: go test -v ./... - test-dotnet: - name: Test .NET SDK - runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/dotnet-sdk - strategy: - matrix: - dotnet-version: ['6.0', '7.0', '8.0'] - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore - - - name: Run tests - run: dotnet test --no-build --verbosity normal - test-java: name: Test Java SDK runs-on: ubuntu-latest defaults: run: working-directory: packages/java-sdk - strategy: - matrix: - java-version: ['11', '17', '21'] steps: - uses: actions/checkout@v4 - - name: Set up JDK ${{ matrix.java-version }} + - name: Set up JDK 11 uses: actions/setup-java@v4 with: - java-version: ${{ matrix.java-version }} + java-version: '11' distribution: 'temurin' cache: 'maven' - name: Run tests run: mvn test -B - - test-ruby: - name: Test Ruby SDK - runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/ruby-sdk - strategy: - matrix: - ruby-version: ['3.1', '3.2', '3.3'] - steps: - - uses: actions/checkout@v4 - - - name: Set up Ruby ${{ matrix.ruby-version }} - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true - working-directory: packages/ruby-sdk - - - name: Run tests - run: bundle exec rspec diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml deleted file mode 100644 index 366e9db4..00000000 --- a/.github/workflows/js-ci.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: JS SDK CI -on: - pull_request: - paths: ["packages/js-sdk/**", "specs/**"] - push: - branches: [main] - paths: ["packages/js-sdk/**", "specs/**"] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm install - - run: npm run build -w packages/js-sdk diff --git a/.github/workflows/publish-dotnet.yml b/.github/workflows/publish-dotnet.yml deleted file mode 100644 index 46a9eb10..00000000 --- a/.github/workflows/publish-dotnet.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Publish .NET SDK - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - if: startsWith(github.ref_name, 'dotnet-sdk-v') - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0' - - - name: Restore dependencies - run: | - cd packages/dotnet-sdk - dotnet restore - - - name: Build - run: | - cd packages/dotnet-sdk - dotnet build --configuration Release --no-restore - - - name: Run tests - run: | - cd packages/dotnet-sdk - dotnet test --configuration Release --no-build --verbosity normal - - publish: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0' - - - name: Extract version from tag - id: version - run: echo "version=${GITHUB_REF_NAME#dotnet-sdk-v}" >> $GITHUB_OUTPUT - - - name: Update version in csproj - run: | - cd packages/dotnet-sdk/src/TurboDocx - sed -i "s/.*<\/Version>/${{ steps.version.outputs.version }}<\/Version>/" TurboDocx.csproj - - - name: Build Release - run: | - cd packages/dotnet-sdk - dotnet build --configuration Release - - - name: Pack - run: | - cd packages/dotnet-sdk/src/TurboDocx - dotnet pack --configuration Release --no-build --output ../../nupkg - - - name: Publish to NuGet - run: | - cd packages/dotnet-sdk - dotnet nuget push nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/publish-ruby.yml b/.github/workflows/publish-ruby.yml deleted file mode 100644 index dabda824..00000000 --- a/.github/workflows/publish-ruby.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Publish Ruby SDK - -on: - release: - types: [published] - workflow_dispatch: - inputs: - version: - description: 'Version to publish (e.g., 1.0.0)' - required: true - type: string - -jobs: - publish: - name: Publish to RubyGems - runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/ruby-sdk - - steps: - - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - working-directory: packages/ruby-sdk - - - name: Update version (workflow_dispatch) - if: github.event_name == 'workflow_dispatch' - run: | - sed -i "s/VERSION = \".*\"/VERSION = \"${{ inputs.version }}\"/" lib/turbodocx/version.rb - - - name: Run tests - run: bundle exec rspec - - - name: Build gem - run: gem build turbodocx.gemspec - - - name: Configure RubyGems credentials - run: | - mkdir -p ~/.gem - echo "---" > ~/.gem/credentials - echo ":rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }}" >> ~/.gem/credentials - chmod 0600 ~/.gem/credentials - - - name: Publish to RubyGems - run: gem push turbodocx-*.gem diff --git a/.github/workflows/py-ci.yml b/.github/workflows/py-ci.yml deleted file mode 100644 index 7004173b..00000000 --- a/.github/workflows/py-ci.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Python SDK CI -on: - pull_request: - paths: ["packages/py-sdk/**", "specs/**"] - push: - branches: [main] - paths: ["packages/py-sdk/**", "specs/**"] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: pip install hatch twine - - run: cd packages/py-sdk && hatch build diff --git a/.gitignore b/.gitignore index ffb8fd05..cfb26f17 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ build/ *.egg-info/ +# TypeScript +*.tsbuildinfo + # Python __pycache__/ *.py[cod] @@ -16,6 +19,9 @@ __pycache__/ .mypy_cache/ .venv/ venv/ +*.whl +*.tar.gz +.hypothesis/ # IDE .idea/ @@ -43,6 +49,14 @@ npm-debug.log* # Go go.sum +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +go.work +go.work.sum # .NET bin/ @@ -56,6 +70,14 @@ TestResults/ target/ *.class *.jar +*.war +*.ear +*.iml +*.iws +*.ipr +.classpath +.project +.settings/ # Ruby *.gem diff --git a/README.md b/README.md index 9a079888..cf27a78c 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,15 @@ Comprehensive SDKs, detailed documentation, and responsive support. Ship faster | **JavaScript/TypeScript** | [@turbodocx/sdk](./packages/js-sdk) | `npm install @turbodocx/sdk` | [View →](./packages/js-sdk#readme) | | **Python** | [turbodocx-sdk](./packages/py-sdk) | `pip install turbodocx-sdk` | [View →](./packages/py-sdk#readme) | | **Go** | [turbodocx-sdk](./packages/go-sdk) | `go get github.com/turbodocx/sdk` | [View →](./packages/go-sdk#readme) | -| **C# / .NET** | [TurboDocx.Sdk](./packages/dotnet-sdk) | `dotnet add package TurboDocx.Sdk` | [View →](./packages/dotnet-sdk#readme) | | **Java** | [com.turbodocx:sdk](./packages/java-sdk) | [Maven Central](https://search.maven.org/artifact/com.turbodocx/sdk) | [View →](./packages/java-sdk#readme) | -| **Ruby** | [turbodocx-sdk](./packages/ruby-sdk) | `gem install turbodocx-sdk` | [View →](./packages/ruby-sdk#readme) | + +### Coming Soon + +| Language | Status | +|:---------|:-------| +| **C# / .NET** | 🚧 In Progress | +| **Ruby** | 🚧 In Progress | +| **PowerShell** | 🚧 In Progress | > 🔌 **Low-code?** Check out our [n8n community node](https://www.npmjs.com/package/@turbodocx/n8n-nodes-turbodocx) for no-code/low-code workflows! > @@ -108,16 +114,6 @@ go get github.com/turbodocx/sdk ``` -
-C# / .NET - -```bash -dotnet add package TurboDocx.Sdk -# or -Install-Package TurboDocx.Sdk -``` -
-
Java @@ -130,16 +126,6 @@ Install-Package TurboDocx.Sdk ```
-
-Ruby - -```bash -gem install turbodocx-sdk -# or add to Gemfile: -gem 'turbodocx-sdk' -``` -
- ### 3. Send your first document for signature ```typescript @@ -306,9 +292,7 @@ await TurboSign.resend(documentId, ['recipient-uuid']); | JavaScript/TypeScript | Node.js 16+ | | Python | Python 3.9+ | | Go | Go 1.21+ | -| .NET | .NET 6.0+ | | Java | Java 11+ | -| Ruby | Ruby 3.0+ | --- diff --git a/packages/dotnet-sdk/LICENSE b/packages/dotnet-sdk/LICENSE deleted file mode 100644 index 907866ef..00000000 --- a/packages/dotnet-sdk/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 TurboDocx - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/dotnet-sdk/README.md b/packages/dotnet-sdk/README.md deleted file mode 100644 index 15d10eef..00000000 --- a/packages/dotnet-sdk/README.md +++ /dev/null @@ -1,408 +0,0 @@ -[![TurboDocx](./banner.png)](https://www.turbodocx.com) - -
- -# TurboDocx.Sdk - -**Official .NET SDK for TurboDocx** - -[![NuGet Version](https://img.shields.io/nuget/v/TurboDocx.Sdk.svg)](https://nuget.org/packages/TurboDocx.Sdk) -[![NuGet Downloads](https://img.shields.io/nuget/dt/TurboDocx.Sdk)](https://nuget.org/packages/TurboDocx.Sdk) -[![.NET](https://img.shields.io/badge/.NET-6.0+-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) - -[Documentation](https://www.turbodocx.com/docs) • [API Reference](https://www.turbodocx.com/docs/api) • [Examples](#examples) • [Discord](https://discord.gg/NYKwz4BcpX) - -
- ---- - -## Features - -- 🚀 **Production-Ready** — Battle-tested, processing thousands of documents daily -- ⚡ **Async-First** — Native async/await with ConfigureAwait support -- 🔒 **Type-Safe** — Full nullable reference type support -- 📝 **IntelliSense** — Comprehensive XML documentation -- 🧵 **Thread-Safe** — Safe for concurrent use with HttpClient pooling -- 🤖 **100% n8n Parity** — Same operations as our n8n community nodes - ---- - -## Installation - -```bash -dotnet add package TurboDocx.Sdk -``` - -
-Other methods - -```bash -# Package Manager Console -Install-Package TurboDocx.Sdk - -# PackageReference - - -# Paket -paket add TurboDocx.Sdk -``` -
- ---- - -## Quick Start - -```csharp -using TurboDocx.Sdk; - -// 1. Create client -var client = new TurboDocxClient("your-api-key"); - -// 2. Send document for signature -var result = await client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest -{ - FileLink = "https://example.com/contract.pdf", - Recipients = new[] - { - new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } - }, - Fields = new[] - { - new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } - } -}); - -Console.WriteLine($"Sign URL: {result.Recipients[0].SignUrl}"); -``` - ---- - -## Configuration - -```csharp -// Basic client -var client = new TurboDocxClient("your-api-key"); - -// With options -var client = new TurboDocxClient(new TurboDocxOptions -{ - ApiKey = Environment.GetEnvironmentVariable("TURBODOCX_API_KEY"), - BaseUrl = "https://custom-api.example.com", // Optional - Timeout = TimeSpan.FromSeconds(30) // Optional -}); - -// With dependency injection (ASP.NET Core) -services.AddTurboDocx(options => -{ - options.ApiKey = Configuration["TurboDocx:ApiKey"]; -}); -``` - -### Dependency Injection - -```csharp -// Startup.cs / Program.cs -builder.Services.AddTurboDocx(options => -{ - options.ApiKey = builder.Configuration["TurboDocx:ApiKey"]; -}); - -// In your service/controller -public class ContractService -{ - private readonly ITurboDocxClient _client; - - public ContractService(ITurboDocxClient client) - { - _client = client; - } - - public async Task SendContractAsync(string pdfUrl) - { - var result = await _client.TurboSign.PrepareForSigningSingleAsync(...); - return result.DocumentId; - } -} -``` - ---- - -## API Reference - -### TurboSign - -#### `PrepareForReviewAsync` - -Upload a document for review without sending signature emails. - -```csharp -var result = await client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest -{ - FileLink = "https://example.com/contract.pdf", - Recipients = new[] - { - new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } - }, - Fields = new[] - { - new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } - }, - DocumentName = "Service Agreement", // Optional - SenderName = "Acme Corp", // Optional - SenderEmail = "contracts@acme.com" // Optional -}); - -Console.WriteLine($"Preview URL: {result.PreviewUrl}"); -Console.WriteLine($"Document ID: {result.DocumentId}"); -``` - -#### `PrepareForSigningSingleAsync` - -Upload a document and immediately send signature request emails. - -```csharp -var result = await client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest -{ - FileLink = "https://example.com/contract.pdf", - Recipients = new[] - { - new Recipient { Name = "Alice", Email = "alice@example.com", Order = 1 }, - new Recipient { Name = "Bob", Email = "bob@example.com", Order = 2 } - }, - Fields = new[] - { - new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 }, - new Field { Type = "signature", Page = 1, X = 100, Y = 600, Width = 200, Height = 50, RecipientOrder = 2 } - } -}); - -foreach (var recipient in result.Recipients) -{ - Console.WriteLine($"{recipient.Name}: {recipient.SignUrl}"); -} -``` - -#### `GetStatusAsync` - -Check the current status of a document. - -```csharp -var status = await client.TurboSign.GetStatusAsync("doc-uuid-here"); - -Console.WriteLine($"Status: {status.Status}"); // "pending", "completed", "voided" - -foreach (var recipient in status.Recipients) -{ - Console.WriteLine($"{recipient.Name}: {recipient.Status}"); -} -``` - -#### `DownloadAsync` - -Download the signed document. - -```csharp -var pdfBytes = await client.TurboSign.DownloadAsync("doc-uuid-here"); - -// Save to file -await File.WriteAllBytesAsync("signed-contract.pdf", pdfBytes); - -// Or return as stream -var stream = new MemoryStream(pdfBytes); -``` - -#### `VoidAsync` - -Cancel a signature request. - -```csharp -await client.TurboSign.VoidAsync("doc-uuid-here", "Contract terms changed"); -``` - -#### `ResendAsync` - -Resend signature request emails. - -```csharp -await client.TurboSign.ResendAsync("doc-uuid-here", new[] { "recipient-uuid-1" }); -``` - ---- - -## Field Types - -| Type | Description | Required | Auto-filled | -|:-----|:------------|:---------|:------------| -| `signature` | Signature field (draw or type) | Yes | No | -| `initials` | Initials field | Yes | No | -| `text` | Free-form text input | No | No | -| `date` | Date stamp | No | Yes (signing date) | -| `checkbox` | Checkbox / agreement | No | No | - ---- - -## Examples - -### Sequential Signing - -```csharp -var result = await client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest -{ - FileLink = "https://example.com/contract.pdf", - Recipients = new[] - { - new Recipient { Name = "Employee", Email = "employee@company.com", Order = 1 }, - new Recipient { Name = "Manager", Email = "manager@company.com", Order = 2 }, - new Recipient { Name = "HR", Email = "hr@company.com", Order = 3 } - }, - Fields = new[] - { - // Employee signs first - new Field { Type = "signature", Page = 1, X = 100, Y = 400, Width = 200, Height = 50, RecipientOrder = 1 }, - new Field { Type = "date", Page = 1, X = 320, Y = 400, Width = 100, Height = 30, RecipientOrder = 1 }, - // Manager signs second - new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 2 }, - // HR signs last - new Field { Type = "signature", Page = 1, X = 100, Y = 600, Width = 200, Height = 50, RecipientOrder = 3 } - } -}); -``` - -### With Cancellation Token - -```csharp -using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - -try -{ - var result = await client.TurboSign.PrepareForSigningSingleAsync(request, cts.Token); -} -catch (OperationCanceledException) -{ - Console.WriteLine("Request was cancelled"); -} -``` - -### Polling for Completion - -```csharp -public async Task WaitForCompletionAsync(string documentId, CancellationToken ct = default) -{ - while (!ct.IsCancellationRequested) - { - var status = await client.TurboSign.GetStatusAsync(documentId, ct); - - switch (status.Status) - { - case "completed": - return await client.TurboSign.DownloadAsync(documentId, ct); - case "voided": - throw new InvalidOperationException("Document was voided"); - } - - await Task.Delay(TimeSpan.FromSeconds(30), ct); - } - - throw new OperationCanceledException(); -} -``` - -### ASP.NET Core Controller - -```csharp -[ApiController] -[Route("api/[controller]")] -public class ContractsController : ControllerBase -{ - private readonly ITurboDocxClient _client; - - public ContractsController(ITurboDocxClient client) - { - _client = client; - } - - [HttpPost("send")] - public async Task SendContract([FromBody] SendContractRequest request) - { - var result = await _client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest - { - FileLink = request.PdfUrl, - Recipients = request.Recipients, - Fields = request.Fields - }); - - return Ok(new { DocumentId = result.DocumentId }); - } -} -``` - ---- - -## Error Handling - -```csharp -try -{ - var result = await client.TurboSign.GetStatusAsync("invalid-id"); -} -catch (TurboDocxException ex) -{ - Console.WriteLine($"Status: {ex.StatusCode}"); - Console.WriteLine($"Message: {ex.Message}"); - Console.WriteLine($"Code: {ex.ErrorCode}"); -} -catch (Exception ex) -{ - Console.WriteLine($"Unexpected error: {ex.Message}"); -} -``` - -### Common Error Codes - -| Status | Meaning | -|:-------|:--------| -| `400` | Bad request — check your parameters | -| `401` | Unauthorized — check your API key | -| `404` | Document not found | -| `429` | Rate limited — slow down requests | -| `500` | Server error — retry with backoff | - ---- - -## Requirements - -- .NET 6.0+ - ---- - -## Related Packages - -| Package | Description | -|:--------|:------------| -| [@turbodocx/sdk (JS)](../js-sdk) | JavaScript/TypeScript SDK | -| [turbodocx-sdk (Python)](../py-sdk) | Python SDK | -| [@turbodocx/n8n-nodes-turbodocx](https://www.npmjs.com/package/@turbodocx/n8n-nodes-turbodocx) | n8n community nodes | - ---- - -## Support - -- 📖 [Documentation](https://www.turbodocx.com/docs) -- 💬 [Discord](https://discord.gg/NYKwz4BcpX) -- 🐛 [GitHub Issues](https://github.com/TurboDocx/SDK/issues) -- 📧 [Email Support](mailto:support@turbodocx.com) - ---- - -## License - -MIT — see [LICENSE](./LICENSE) - ---- - -
- -[![TurboDocx](./footer.png)](https://www.turbodocx.com) - -
diff --git a/packages/dotnet-sdk/TurboDocx.sln b/packages/dotnet-sdk/TurboDocx.sln deleted file mode 100644 index 0f2f6c9b..00000000 --- a/packages/dotnet-sdk/TurboDocx.sln +++ /dev/null @@ -1,24 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TurboDocx", "src\TurboDocx\TurboDocx.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TurboDocx.Tests", "tests\TurboDocx.Tests\TurboDocx.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/packages/dotnet-sdk/banner.png b/packages/dotnet-sdk/banner.png deleted file mode 100644 index e1e45c21..00000000 Binary files a/packages/dotnet-sdk/banner.png and /dev/null differ diff --git a/packages/dotnet-sdk/footer.png b/packages/dotnet-sdk/footer.png deleted file mode 100644 index 7a6ced08..00000000 Binary files a/packages/dotnet-sdk/footer.png and /dev/null differ diff --git a/packages/dotnet-sdk/src/TurboDocx/HttpClient.cs b/packages/dotnet-sdk/src/TurboDocx/HttpClient.cs deleted file mode 100644 index 7b64d450..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/HttpClient.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace TurboDocx; - -/// -/// HTTP client for TurboDocx API requests -/// -internal class HttpClient : IDisposable -{ - private readonly System.Net.Http.HttpClient _client; - private readonly TurboDocxClientConfig _config; - private readonly string _baseUrl; - private bool _disposed; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - public HttpClient(TurboDocxClientConfig config) - { - _config = config; - _baseUrl = config.BaseUrl ?? "https://api.turbodocx.com"; - _client = new System.Net.Http.HttpClient - { - Timeout = TimeSpan.FromSeconds(30) - }; - } - - private void SetHeaders(HttpRequestMessage request, bool includeContentType = true) - { - if (includeContentType) - { - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - } - - if (!string.IsNullOrEmpty(_config.AccessToken)) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.AccessToken); - } - else if (!string.IsNullOrEmpty(_config.ApiKey)) - { - request.Headers.Add("X-API-Key", _config.ApiKey); - } - } - - private async Task HandleErrorResponse(HttpResponseMessage response) - { - var content = await response.Content.ReadAsStringAsync(); - string message = $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; - string? code = null; - - try - { - var errorData = JsonSerializer.Deserialize(content, JsonOptions); - if (errorData != null) - { - message = errorData.Message ?? errorData.Error ?? message; - code = errorData.Code; - } - } - catch - { - // Use default message if parsing fails - } - - throw new TurboDocxException(message, (int)response.StatusCode, code); - } - - public async Task GetAsync(string path, CancellationToken cancellationToken = default) - { - var request = new HttpRequestMessage(HttpMethod.Get, _baseUrl + path); - SetHeaders(request); - - var response = await _client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - return result!; - } - - public async Task GetRawAsync(string path, CancellationToken cancellationToken = default) - { - var request = new HttpRequestMessage(HttpMethod.Get, _baseUrl + path); - SetHeaders(request, includeContentType: false); - - var response = await _client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - return await response.Content.ReadAsByteArrayAsync(cancellationToken); - } - - public async Task PostAsync(string path, object? data, CancellationToken cancellationToken = default) - { - var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); - SetHeaders(request); - - if (data != null) - { - var json = JsonSerializer.Serialize(data, JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var response = await _client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - return result!; - } - - public async Task UploadFileAsync( - string path, - byte[] file, - string fileName, - Dictionary? additionalData, - CancellationToken cancellationToken = default) - { - var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); - SetHeaders(request, includeContentType: false); - - var content = new MultipartFormDataContent(); - content.Add(new ByteArrayContent(file), "file", fileName); - - if (additionalData != null) - { - foreach (var (key, value) in additionalData) - { - content.Add(new StringContent(value), key); - } - } - - request.Content = content; - - var response = await _client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - return result!; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _client.Dispose(); - } - _disposed = true; - } - } - - private class ErrorResponse - { - public string? Message { get; set; } - public string? Error { get; set; } - public string? Code { get; set; } - } -} diff --git a/packages/dotnet-sdk/src/TurboDocx/TurboDocx.csproj b/packages/dotnet-sdk/src/TurboDocx/TurboDocx.csproj deleted file mode 100644 index 40c9fb06..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/TurboDocx.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - net6.0;net7.0;net8.0 - enable - enable - latest - - - TurboDocx.SDK - 0.1.0 - TurboDocx - TurboDocx - Official .NET SDK for TurboDocx API - Digital signatures, document generation, and AI-powered workflows - turbodocx;turbosign;esignature;digital-signature;document-automation;pdf;api;sdk - https://www.turbodocx.com - https://github.com/TurboDocx/SDK - git - MIT - README.md - - - true - $(NoWarn);CS1591 - - - - - - - - - - - diff --git a/packages/dotnet-sdk/src/TurboDocx/TurboDocxClient.cs b/packages/dotnet-sdk/src/TurboDocx/TurboDocxClient.cs deleted file mode 100644 index 046f316d..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/TurboDocxClient.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; - -namespace TurboDocx; - -/// -/// Main TurboDocx API client -/// -public class TurboDocxClient : IDisposable -{ - private readonly HttpClient _httpClient; - private bool _disposed; - - /// - /// TurboSign module for digital signature operations - /// - public TurboSignClient TurboSign { get; } - - /// - /// Creates a new TurboDocx client with the given API key - /// - /// Your TurboDocx API key - /// Optional custom base URL - public TurboDocxClient(string apiKey, string? baseUrl = null) - : this(new TurboDocxClientConfig { ApiKey = apiKey, BaseUrl = baseUrl }) - { - } - - /// - /// Creates a new TurboDocx client with custom configuration - /// - /// Client configuration - public TurboDocxClient(TurboDocxClientConfig config) - { - if (string.IsNullOrEmpty(config.ApiKey) && string.IsNullOrEmpty(config.AccessToken)) - { - throw new ArgumentException("API key or access token is required"); - } - - _httpClient = new HttpClient(config); - TurboSign = new TurboSignClient(_httpClient); - } - - /// - /// Disposes the client and releases resources - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _httpClient.Dispose(); - } - _disposed = true; - } - } -} - -/// -/// Configuration options for TurboDocxClient -/// -public class TurboDocxClientConfig -{ - /// - /// TurboDocx API key - /// - public string? ApiKey { get; set; } - - /// - /// OAuth2 access token (alternative to ApiKey) - /// - public string? AccessToken { get; set; } - - /// - /// API base URL (default: https://api.turbodocx.com) - /// - public string? BaseUrl { get; set; } -} diff --git a/packages/dotnet-sdk/src/TurboDocx/TurboDocxException.cs b/packages/dotnet-sdk/src/TurboDocx/TurboDocxException.cs deleted file mode 100644 index 81f865d1..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/TurboDocxException.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; - -namespace TurboDocx; - -/// -/// Exception thrown when TurboDocx API returns an error -/// -public class TurboDocxException : Exception -{ - /// - /// HTTP status code - /// - public int StatusCode { get; } - - /// - /// Error code from API (if provided) - /// - public string? Code { get; } - - /// - /// Creates a new TurboDocxException - /// - public TurboDocxException(string message, int statusCode, string? code = null) - : base(message) - { - StatusCode = statusCode; - Code = code; - } -} diff --git a/packages/dotnet-sdk/src/TurboDocx/TurboSign/Models.cs b/packages/dotnet-sdk/src/TurboDocx/TurboSign/Models.cs deleted file mode 100644 index 6661e753..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/TurboSign/Models.cs +++ /dev/null @@ -1,199 +0,0 @@ -namespace TurboDocx; - -/// -/// Common interface for signature requests -/// -public interface ISignatureRequest -{ - string? DocumentName { get; } - string? DocumentDescription { get; } - string? SenderName { get; } - string? SenderEmail { get; } - string[]? CcEmails { get; } -} - -/// -/// Recipient for signature -/// -public class Recipient -{ - public string Name { get; set; } = ""; - public string Email { get; set; } = ""; - public int Order { get; set; } -} - -/// -/// Signature field -/// -public class Field -{ - public string Type { get; set; } = "signature"; - public int? Page { get; set; } - public int? X { get; set; } - public int? Y { get; set; } - public int? Width { get; set; } - public int? Height { get; set; } - public int RecipientOrder { get; set; } -} - -/// -/// Request for PrepareForReview -/// -public class PrepareForReviewRequest : ISignatureRequest -{ - /// File content (use this OR FileLink/DeliverableId/TemplateId) - public byte[]? File { get; set; } - - /// Original filename - public string? FileName { get; set; } - - /// URL to document file - public string? FileLink { get; set; } - - /// TurboDocx deliverable ID - public string? DeliverableId { get; set; } - - /// TurboDocx template ID - public string? TemplateId { get; set; } - - /// Recipients who will sign - public Recipient[] Recipients { get; set; } = Array.Empty(); - - /// Signature fields configuration - public Field[] Fields { get; set; } = Array.Empty(); - - /// Document name - public string? DocumentName { get; set; } - - /// Document description - public string? DocumentDescription { get; set; } - - /// Sender name - public string? SenderName { get; set; } - - /// Sender email - public string? SenderEmail { get; set; } - - /// CC email addresses - public string[]? CcEmails { get; set; } -} - -/// -/// Response from PrepareForReview -/// -public class PrepareForReviewResponse -{ - public string DocumentId { get; set; } = ""; - public string Status { get; set; } = ""; - public string? PreviewUrl { get; set; } - public RecipientStatusResponse[]? Recipients { get; set; } -} - -/// -/// Request for PrepareForSigningSingle -/// -public class PrepareForSigningRequest : ISignatureRequest -{ - /// File content (use this OR FileLink/DeliverableId/TemplateId) - public byte[]? File { get; set; } - - /// Original filename - public string? FileName { get; set; } - - /// URL to document file - public string? FileLink { get; set; } - - /// TurboDocx deliverable ID - public string? DeliverableId { get; set; } - - /// TurboDocx template ID - public string? TemplateId { get; set; } - - /// Recipients who will sign - public Recipient[] Recipients { get; set; } = Array.Empty(); - - /// Signature fields configuration - public Field[] Fields { get; set; } = Array.Empty(); - - /// Document name - public string? DocumentName { get; set; } - - /// Document description - public string? DocumentDescription { get; set; } - - /// Sender name - public string? SenderName { get; set; } - - /// Sender email - public string? SenderEmail { get; set; } - - /// CC email addresses - public string[]? CcEmails { get; set; } -} - -/// -/// Response from PrepareForSigningSingle -/// -public class PrepareForSigningResponse -{ - public string DocumentId { get; set; } = ""; - public string Status { get; set; } = ""; - public RecipientSignResponse[] Recipients { get; set; } = Array.Empty(); -} - -/// -/// Recipient status in response -/// -public class RecipientStatusResponse -{ - public string Id { get; set; } = ""; - public string Name { get; set; } = ""; - public string Email { get; set; } = ""; - public string Status { get; set; } = ""; -} - -/// -/// Recipient with sign URL -/// -public class RecipientSignResponse -{ - public string Id { get; set; } = ""; - public string Name { get; set; } = ""; - public string Email { get; set; } = ""; - public string Status { get; set; } = ""; - public string? SignUrl { get; set; } -} - -/// -/// Document status response -/// -public class DocumentStatusResponse -{ - public string DocumentId { get; set; } = ""; - public string Status { get; set; } = ""; - public string Name { get; set; } = ""; - public RecipientStatusResponse[] Recipients { get; set; } = Array.Empty(); - public string CreatedAt { get; set; } = ""; - public string UpdatedAt { get; set; } = ""; - public string? CompletedAt { get; set; } -} - -/// -/// Void document response -/// -public class VoidDocumentResponse -{ - public string DocumentId { get; set; } = ""; - public string Status { get; set; } = ""; - public string VoidedAt { get; set; } = ""; -} - -/// -/// Resend email response -/// -public class ResendEmailResponse -{ - public string DocumentId { get; set; } = ""; - public string Message { get; set; } = ""; - public string ResentAt { get; set; } = ""; -} diff --git a/packages/dotnet-sdk/src/TurboDocx/TurboSign/TurboSignClient.cs b/packages/dotnet-sdk/src/TurboDocx/TurboSign/TurboSignClient.cs deleted file mode 100644 index f795914c..00000000 --- a/packages/dotnet-sdk/src/TurboDocx/TurboSign/TurboSignClient.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Text.Json; - -namespace TurboDocx; - -/// -/// TurboSign client for digital signature operations. -/// Provides 100% parity with n8n-nodes-turbodocx. -/// -public class TurboSignClient -{ - private readonly HttpClient _http; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - internal TurboSignClient(HttpClient http) - { - _http = http; - } - - /// - /// Prepares a document for review without sending emails. - /// Use this to preview field placement before sending. - /// - public async Task PrepareForReviewAsync( - PrepareForReviewRequest request, - CancellationToken cancellationToken = default) - { - var formData = new Dictionary - { - ["recipients"] = JsonSerializer.Serialize(request.Recipients, JsonOptions), - ["fields"] = JsonSerializer.Serialize(request.Fields, JsonOptions) - }; - - AddOptionalFields(formData, request); - - ApiResponse response; - - if (request.File != null) - { - response = await _http.UploadFileAsync>( - "/turbosign/single/prepare-for-review", - request.File, - request.FileName ?? "document.pdf", - formData, - cancellationToken); - } - else - { - if (!string.IsNullOrEmpty(request.FileLink)) - formData["fileLink"] = request.FileLink; - if (!string.IsNullOrEmpty(request.DeliverableId)) - formData["deliverableId"] = request.DeliverableId; - if (!string.IsNullOrEmpty(request.TemplateId)) - formData["templateId"] = request.TemplateId; - - response = await _http.PostAsync>( - "/turbosign/single/prepare-for-review", - formData, - cancellationToken); - } - - return response.Data; - } - - /// - /// Prepares a document for signing and sends emails in a single call. - /// This is the n8n-equivalent "Prepare for Signing" operation. - /// - public async Task PrepareForSigningSingleAsync( - PrepareForSigningRequest request, - CancellationToken cancellationToken = default) - { - var formData = new Dictionary - { - ["recipients"] = JsonSerializer.Serialize(request.Recipients, JsonOptions), - ["fields"] = JsonSerializer.Serialize(request.Fields, JsonOptions) - }; - - AddOptionalFields(formData, request); - - ApiResponse response; - - if (request.File != null) - { - response = await _http.UploadFileAsync>( - "/turbosign/single/prepare-for-signing", - request.File, - request.FileName ?? "document.pdf", - formData, - cancellationToken); - } - else - { - if (!string.IsNullOrEmpty(request.FileLink)) - formData["fileLink"] = request.FileLink; - if (!string.IsNullOrEmpty(request.DeliverableId)) - formData["deliverableId"] = request.DeliverableId; - if (!string.IsNullOrEmpty(request.TemplateId)) - formData["templateId"] = request.TemplateId; - - response = await _http.PostAsync>( - "/turbosign/single/prepare-for-signing", - formData, - cancellationToken); - } - - return response.Data; - } - - /// - /// Gets the status of a document - /// - public async Task GetStatusAsync( - string documentId, - CancellationToken cancellationToken = default) - { - var response = await _http.GetAsync>( - $"/turbosign/documents/{documentId}/status", - cancellationToken); - return response.Data; - } - - /// - /// Downloads the signed document as bytes - /// - public async Task DownloadAsync( - string documentId, - CancellationToken cancellationToken = default) - { - return await _http.GetRawAsync( - $"/turbosign/documents/{documentId}/download", - cancellationToken); - } - - /// - /// Voids a document (cancels signature request) - /// - public async Task VoidDocumentAsync( - string documentId, - string reason, - CancellationToken cancellationToken = default) - { - var response = await _http.PostAsync>( - $"/turbosign/documents/{documentId}/void", - new { reason }, - cancellationToken); - return response.Data; - } - - /// - /// Resends signature request email to recipients - /// - public async Task ResendEmailAsync( - string documentId, - string[] recipientIds, - CancellationToken cancellationToken = default) - { - var response = await _http.PostAsync>( - $"/turbosign/documents/{documentId}/resend-email", - new { recipientIds }, - cancellationToken); - return response.Data; - } - - private static void AddOptionalFields(Dictionary formData, ISignatureRequest request) - { - if (!string.IsNullOrEmpty(request.DocumentName)) - formData["documentName"] = request.DocumentName; - if (!string.IsNullOrEmpty(request.DocumentDescription)) - formData["documentDescription"] = request.DocumentDescription; - if (!string.IsNullOrEmpty(request.SenderName)) - formData["senderName"] = request.SenderName; - if (!string.IsNullOrEmpty(request.SenderEmail)) - formData["senderEmail"] = request.SenderEmail; - if (request.CcEmails != null && request.CcEmails.Length > 0) - formData["ccEmails"] = string.Join(",", request.CcEmails); - } - - private class ApiResponse - { - public T Data { get; set; } = default!; - } -} diff --git a/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboDocx.Tests.csproj b/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboDocx.Tests.csproj deleted file mode 100644 index 44b6e46e..00000000 --- a/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboDocx.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - enable - enable - false - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - diff --git a/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboSignTests.cs b/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboSignTests.cs deleted file mode 100644 index d2f9f7bd..00000000 --- a/packages/dotnet-sdk/tests/TurboDocx.Tests/TurboSignTests.cs +++ /dev/null @@ -1,411 +0,0 @@ -using System.Text.Json; -using WireMock.RequestBuilders; -using WireMock.ResponseBuilders; -using WireMock.Server; -using Xunit; - -namespace TurboDocx.Tests; - -public class TurboSignTests : IDisposable -{ - private readonly WireMockServer _server; - private readonly TurboDocxClient _client; - - public TurboSignTests() - { - _server = WireMockServer.Start(); - _client = new TurboDocxClient("test-api-key", _server.Url); - } - - public void Dispose() - { - _client.Dispose(); - _server.Dispose(); - } - - [Fact] - public async Task PrepareForReview_WithFileUrl_ReturnsDocumentId() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-review") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new - { - documentId = "doc-123", - status = "review_ready", - previewUrl = "https://preview.example.com/doc-123" - } - }))); - - var result = await _client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest - { - FileLink = "https://storage.example.com/contract.pdf", - Recipients = new[] - { - new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } - }, - Fields = new[] - { - new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } - } - }); - - Assert.Equal("doc-123", result.DocumentId); - Assert.Equal("review_ready", result.Status); - Assert.Equal("https://preview.example.com/doc-123", result.PreviewUrl); - } - - [Fact] - public async Task PrepareForReview_WithDeliverableId_ReturnsDocumentId() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-review") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new { documentId = "doc-456", status = "review_ready" } - }))); - - var result = await _client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest - { - DeliverableId = "deliverable-abc", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } } - }); - - Assert.Equal("doc-456", result.DocumentId); - } - - [Fact] - public async Task PrepareForSigningSingle_SendsAndReturnsSignUrl() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-signing") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new - { - documentId = "doc-123", - status = "sent", - recipients = new[] - { - new - { - id = "rec-1", - name = "John Doe", - email = "john@example.com", - status = "pending", - signUrl = "https://sign.example.com/rec-1" - } - } - } - }))); - - var result = await _client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest - { - FileLink = "https://storage.example.com/contract.pdf", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } } - }); - - Assert.Equal("doc-123", result.DocumentId); - Assert.Equal("sent", result.Status); - Assert.Single(result.Recipients); - Assert.Equal("https://sign.example.com/rec-1", result.Recipients[0].SignUrl); - } - - [Fact] - public async Task GetStatus_ReturnsDocumentStatus() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/documents/doc-123/status") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new - { - documentId = "doc-123", - status = "pending", - name = "Test Document", - recipients = new[] - { - new { id = "rec-1", name = "John Doe", email = "john@example.com", status = "pending" } - }, - createdAt = "2024-01-01T00:00:00Z", - updatedAt = "2024-01-01T00:00:00Z" - } - }))); - - var result = await _client.TurboSign.GetStatusAsync("doc-123"); - - Assert.Equal("doc-123", result.DocumentId); - Assert.Equal("pending", result.Status); - Assert.Equal("Test Document", result.Name); - } - - [Fact] - public async Task Download_ReturnsPdfBytes() - { - var expectedContent = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // %PDF - - _server - .Given(Request.Create() - .WithPath("/turbosign/documents/doc-123/download") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/pdf") - .WithBody(expectedContent)); - - var result = await _client.TurboSign.DownloadAsync("doc-123"); - - Assert.Equal(expectedContent, result); - } - - [Fact] - public async Task VoidDocument_ReturnsVoidedStatus() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/documents/doc-123/void") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new - { - documentId = "doc-123", - status = "voided", - voidedAt = "2024-01-01T12:00:00Z" - } - }))); - - var result = await _client.TurboSign.VoidDocumentAsync("doc-123", "Document needs revision"); - - Assert.Equal("doc-123", result.DocumentId); - Assert.Equal("voided", result.Status); - } - - [Fact] - public async Task ResendEmail_ReturnsConfirmation() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/documents/doc-123/resend-email") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new - { - documentId = "doc-123", - message = "Emails resent successfully", - resentAt = "2024-01-01T12:00:00Z" - } - }))); - - var result = await _client.TurboSign.ResendEmailAsync("doc-123", new[] { "rec-1", "rec-2" }); - - Assert.Contains("resent", result.Message.ToLower()); - } - - [Fact] - public async Task ApiError_ThrowsTurboDocxException() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/documents/invalid-doc/status") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(404) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - message = "Document not found", - code = "DOCUMENT_NOT_FOUND" - }))); - - var exception = await Assert.ThrowsAsync( - () => _client.TurboSign.GetStatusAsync("invalid-doc")); - - Assert.Equal(404, exception.StatusCode); - Assert.Equal("Document not found", exception.Message); - Assert.Equal("DOCUMENT_NOT_FOUND", exception.Code); - } - - [Fact] - public void Client_WithoutApiKey_ThrowsArgumentException() - { - Assert.Throws(() => new TurboDocxClient(new TurboDocxClientConfig())); - } - - [Fact] - public void Client_WithCustomBaseUrl_Configures() - { - using var client = new TurboDocxClient(new TurboDocxClientConfig - { - ApiKey = "test-api-key", - BaseUrl = "https://custom-api.example.com" - }); - Assert.NotNull(client); - Assert.NotNull(client.TurboSign); - } - - [Fact] - public async Task PrepareForReview_WithTemplateId_ReturnsDocumentId() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-review") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new { documentId = "doc-template", status = "review_ready" } - }))); - - var result = await _client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest - { - TemplateId = "template-xyz", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } } - }); - - Assert.Equal("doc-template", result.DocumentId); - } - - [Fact] - public async Task PrepareForReview_WithOptionalFields_IncludesAllFields() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-review") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new { documentId = "doc-789", status = "review_ready" } - }))); - - var result = await _client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest - { - FileLink = "https://example.com/doc.pdf", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } }, - DocumentName = "Test Contract", - DocumentDescription = "A test contract", - SenderName = "Sales Team", - SenderEmail = "sales@company.com", - CcEmails = new[] { "admin@company.com", "legal@company.com" } - }); - - Assert.Equal("doc-789", result.DocumentId); - } - - [Fact] - public async Task PrepareForReview_WithFileUpload_ReturnsDocumentId() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-review") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new { documentId = "doc-upload", status = "review_ready" } - }))); - - var result = await _client.TurboSign.PrepareForReviewAsync(new PrepareForReviewRequest - { - File = new byte[] { 0x25, 0x50, 0x44, 0x46 }, - FileName = "contract.pdf", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } } - }); - - Assert.Equal("doc-upload", result.DocumentId); - } - - [Fact] - public async Task PrepareForSigningSingle_WithFileUpload_ReturnsDocumentId() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-signing") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - data = new { documentId = "doc-upload", status = "sent", recipients = Array.Empty() } - }))); - - var result = await _client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest - { - File = new byte[] { 0x25, 0x50, 0x44, 0x46 }, - FileName = "contract.pdf", - Recipients = new[] { new Recipient { Name = "John Doe", Email = "john@example.com", Order = 1 } }, - Fields = new[] { new Field { Type = "signature", Page = 1, X = 100, Y = 500, Width = 200, Height = 50, RecipientOrder = 1 } } - }); - - Assert.Equal("doc-upload", result.DocumentId); - } - - [Fact] - public async Task ValidationError_ThrowsTurboDocxException() - { - _server - .Given(Request.Create() - .WithPath("/turbosign/single/prepare-for-signing") - .UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(400) - .WithHeader("Content-Type", "application/json") - .WithBody(JsonSerializer.Serialize(new - { - message = "Validation failed: Invalid email format", - code = "VALIDATION_ERROR" - }))); - - var exception = await Assert.ThrowsAsync( - () => _client.TurboSign.PrepareForSigningSingleAsync(new PrepareForSigningRequest - { - FileLink = "https://example.com/doc.pdf", - Recipients = new[] { new Recipient { Name = "Test", Email = "invalid-email", Order = 1 } }, - Fields = Array.Empty() - })); - - Assert.Equal(400, exception.StatusCode); - Assert.Contains("Validation", exception.Message); - } -} diff --git a/packages/go-sdk/README.md b/packages/go-sdk/README.md index 971a4b93..0efb6285 100644 --- a/packages/go-sdk/README.md +++ b/packages/go-sdk/README.md @@ -288,6 +288,50 @@ func sendContractHandler(w http.ResponseWriter, r *http.Request) { --- +## Local Testing + +The SDK includes a comprehensive manual test program to verify all functionality locally. + +### Running Manual Tests + +```bash +# Navigate to the SDK directory +cd packages/go-sdk + +# Run the manual test program +go run cmd/manual/main.go +``` + +### What It Tests + +The `cmd/manual/main.go` program tests all SDK methods: +- ✅ `PrepareForReview()` - Document upload for review +- ✅ `PrepareForSigningSingle()` - Send for signature +- ✅ `GetStatus()` - Check document status +- ✅ `Download()` - Download signed document +- ✅ `Void()` - Cancel signature request +- ✅ `Resend()` - Resend signature emails + +### Configuration + +Before running, update the hardcoded values in `cmd/manual/main.go`: +- `apiKey` - Your TurboDocx API key +- `baseURL` - API endpoint (default: `http://localhost:3000`) +- `orgID` - Your organization UUID +- `testFilePath` - Path to a test PDF/DOCX file +- `testEmail` - Email address for testing + +### Expected Output + +The test program will: +1. Upload a test document +2. Send it for signature +3. Check the status +4. Test void and resend operations +5. Print results for each operation + +--- + ## Error Handling ```go diff --git a/packages/go-sdk/cmd/manual/main.go b/packages/go-sdk/cmd/manual/main.go new file mode 100644 index 00000000..84ebb421 --- /dev/null +++ b/packages/go-sdk/cmd/manual/main.go @@ -0,0 +1,297 @@ +//go:build manual +// +build manual + +/* +TurboSign Go SDK - Manual Test Suite + +Run: go run -tags manual manual_runner.go + +Make sure to configure the values below before running. +*/ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" +) + +// ============================================= +// CONFIGURE THESE VALUES BEFORE RUNNING +// ============================================= +const ( + apiKey = "TDX-your-api-key-here" // Replace with your actual TurboDocx API key + baseURL = "http://localhost:3000" // Replace with your API URL + orgID = "your-organization-uuid-here" // Replace with your organization UUID + testPDFPath = "/path/to/your/test-document.pdf" // Replace with path to your test PDF/DOCX + testEmail = "test-recipient@example.com" // Replace with a real email to receive notifications + fileURL = "https://example.com/sample-document.pdf" // Replace with publicly accessible PDF URL +) + +var client *turbodocx.Client + +func init() { + var err error + client, err = turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ + APIKey: apiKey, + BaseURL: baseURL, + OrgID: orgID, + }) + if err != nil { + fmt.Printf("Failed to create client: %v\n", err) + os.Exit(1) + } +} + +// prettyPrint prints a value as formatted JSON +func prettyPrint(v interface{}) { + b, _ := json.MarshalIndent(v, "", " ") + fmt.Println("Result:", string(b)) +} + +// ============================================= +// TEST FUNCTIONS +// ============================================= + +func testCreateSignatureReviewLink(ctx context.Context) (string, error) { + fmt.Println("\n--- Test 1: CreateSignatureReviewLink (using fileLink) ---") + + // Using fileLink instead of file upload + result, err := client.TurboSign.CreateSignatureReviewLink(ctx, &turbodocx.CreateSignatureReviewLinkRequest{ + FileLink: fileURL, + Recipients: []turbodocx.Recipient{ + {Name: "Signer One", Email: testEmail, SigningOrder: 1}, + }, + Fields: []turbodocx.Field{ + { + RecipientEmail: testEmail, + Type: "signature", + Page: 1, + X: 100, + Y: 550, + Width: 200, + Height: 50, + }, + { + RecipientEmail: testEmail, + Type: "checkbox", + Page: 1, + X: 320, + Y: 550, + Width: 50, + Height: 50, + DefaultValue: "true", + }, + }, + DocumentName: "Review Test Document (fileLink)", + }) + + if err != nil { + return "", err + } + + prettyPrint(result) + return result.DocumentID, nil +} + +func testSendSignature(ctx context.Context, pdfBytes []byte) (string, error) { + fmt.Println("\n--- Test 2: SendSignature (using file buffer with template fields) ---") + + result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ + File: pdfBytes, + Recipients: []turbodocx.Recipient{ + {Name: "Test User", Email: testEmail, SigningOrder: 1}, + }, + Fields: []turbodocx.Field{ + // Template-based field using anchor text (like Java/Python tests) + { + RecipientEmail: testEmail, + Type: "text", + DefaultValue: "Sample Text", + IsMultiline: true, + Required: true, + Template: &turbodocx.TemplateAnchor{ + Anchor: "{placeholder}", + Placement: "replace", + Size: &turbodocx.Size{Width: 200, Height: 80}, + Offset: &turbodocx.Point{X: 0, Y: 0}, + CaseSensitive: true, + UseRegex: false, + }, + }, + // Coordinate-based field (traditional approach) + { + RecipientEmail: testEmail, + Type: "last_name", + Page: 1, + X: 100, + Y: 650, + Width: 200, + Height: 50, + DefaultValue: "Doe", + }, + }, + DocumentName: "Signing Test Document (Template Fields)", + DocumentDescription: "Testing template-based field positioning", + SenderName: "Test Sender", + SenderEmail: "sender@example.com", + CCEmails: []string{"cc@example.com"}, + }) + + if err != nil { + return "", err + } + + prettyPrint(result) + return result.DocumentID, nil +} + +func testGetStatus(ctx context.Context, documentID string) error { + fmt.Println("\n--- Test 3: GetStatus ---") + + result, err := client.TurboSign.GetStatus(ctx, documentID) + if err != nil { + return err + } + + prettyPrint(result) + return nil +} + +func testDownload(ctx context.Context, documentID string) error { + fmt.Println("\n--- Test 4: Download ---") + + result, err := client.TurboSign.Download(ctx, documentID) + if err != nil { + return err + } + + fmt.Printf("Result: PDF received, size: %d bytes\n", len(result)) + + // Save to file + outputPath := "./downloaded-document.pdf" + err = os.WriteFile(outputPath, result, 0644) + if err != nil { + return fmt.Errorf("failed to save file: %w", err) + } + fmt.Printf("File saved to: %s\n", outputPath) + + return nil +} + +func testResend(ctx context.Context, documentID string, recipientIDs []string) error { + fmt.Println("\n--- Test 5: ResendEmail ---") + + result, err := client.TurboSign.ResendEmail(ctx, documentID, recipientIDs) + if err != nil { + return err + } + + prettyPrint(result) + return nil +} + +func testVoid(ctx context.Context, documentID string) error { + fmt.Println("\n--- Test 6: VoidDocument ---") + + result, err := client.TurboSign.VoidDocument(ctx, documentID, "Testing void functionality") + if err != nil { + return err + } + + prettyPrint(result) + return nil +} + +func testGetAuditTrail(ctx context.Context, documentID string) error { + fmt.Println("\n--- Test 7: GetAuditTrail ---") + + result, err := client.TurboSign.GetAuditTrail(ctx, documentID) + if err != nil { + return err + } + + prettyPrint(result) + return nil +} + +// ============================================= +// MAIN TEST RUNNER +// ============================================= + +func main() { + fmt.Println("==============================================") + fmt.Println("TurboSign Go SDK - Manual Test Suite") + fmt.Println("==============================================") + + // Check if test PDF exists + if _, err := os.Stat(testPDFPath); os.IsNotExist(err) { + fmt.Printf("\nError: Test PDF not found at %s\n", testPDFPath) + fmt.Println("Please add a test PDF file and update testPDFPath.") + os.Exit(1) + } + + pdfBytes, err := os.ReadFile(testPDFPath) + if err != nil { + fmt.Printf("Failed to read test PDF: %v\n", err) + os.Exit(1) + } + + ctx := context.Background() + + // Uncomment and run tests as needed: + _ = pdfBytes // Suppress unused variable warning + + // Test 1: Prepare for Review (uses fileLink, doesn't need pdfBytes) + // _, err = testCreateSignatureReviewLink(ctx) + // if err != nil { handleError(err); return } + + // Test 2: Prepare for Signing (creates a new document) + // _, err = testSendSignature(ctx, pdfBytes) + // if err != nil { handleError(err); return } + + // Test 3: Get Status (replace with actual document ID) + // err = testGetStatus(ctx, "document-uuid-here") + // if err != nil { handleError(err); return } + + // Test 4: Download (replace with actual document ID) + // err = testDownload(ctx, "document-uuid-here") + // if err != nil { handleError(err); return } + + // Test 5: Resend (replace with actual document ID and recipient ID) + // err = testResend(ctx, "document-uuid-here", []string{"recipient-uuid-here"}) + // if err != nil { handleError(err); return } + + // Test 6: Void (do this last as it cancels the document) + // err = testVoid(ctx, "document-uuid-here") + // if err != nil { handleError(err); return } + + // Test 7: Get Audit Trail (replace with actual document ID) + // err = testGetAuditTrail(ctx, "document-uuid-here") + // if err != nil { handleError(err); return } + + _ = ctx // Suppress unused variable warning + + fmt.Println("\n==============================================") + fmt.Println("All tests completed successfully!") + fmt.Println("==============================================") +} + +func handleError(err error) { + fmt.Println("\n==============================================") + fmt.Println("TEST FAILED") + fmt.Println("==============================================") + fmt.Printf("Error: %v\n", err) + + if tdErr, ok := err.(*turbodocx.TurboDocxError); ok { + fmt.Printf("Status Code: %d\n", tdErr.StatusCode) + if tdErr.Code != "" { + fmt.Printf("Error Code: %s\n", tdErr.Code) + } + } + + os.Exit(1) +} diff --git a/packages/go-sdk/go.mod b/packages/go-sdk/go.mod index 46c5463d..d14b48ce 100644 --- a/packages/go-sdk/go.mod +++ b/packages/go-sdk/go.mod @@ -2,9 +2,7 @@ module github.com/TurboDocx/SDK/packages/go-sdk go 1.21 -require ( - github.com/stretchr/testify v1.9.0 -) +require github.com/stretchr/testify v1.9.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/packages/go-sdk/http.go b/packages/go-sdk/http.go index c20653df..fa3227b3 100644 --- a/packages/go-sdk/http.go +++ b/packages/go-sdk/http.go @@ -8,9 +8,65 @@ import ( "io" "mime/multipart" "net/http" + "net/textproto" + "os" + "path/filepath" + "strings" "time" ) +// FileTypeInfo contains detected file type information +type FileTypeInfo struct { + MimeType string + Extension string +} + +// DetectFileType detects file type from magic bytes +func DetectFileType(fileBytes []byte) FileTypeInfo { + if len(fileBytes) < 4 { + return FileTypeInfo{MimeType: "application/octet-stream", Extension: "bin"} + } + + // PDF: %PDF (0x25 0x50 0x44 0x46) + if fileBytes[0] == 0x25 && fileBytes[1] == 0x50 && fileBytes[2] == 0x44 && fileBytes[3] == 0x46 { + return FileTypeInfo{MimeType: "application/pdf", Extension: "pdf"} + } + + // ZIP-based formats (DOCX, PPTX): starts with PK (0x50 0x4B) + if fileBytes[0] == 0x50 && fileBytes[1] == 0x4B { + headerLen := len(fileBytes) + if headerLen > 2000 { + headerLen = 2000 + } + header := string(fileBytes[:headerLen]) + + // PPTX contains 'ppt/' in the ZIP structure + if strings.Contains(header, "ppt/") { + return FileTypeInfo{ + MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + Extension: "pptx", + } + } + + // DOCX contains 'word/' in the ZIP structure + if strings.Contains(header, "word/") { + return FileTypeInfo{ + MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Extension: "docx", + } + } + + // Default to DOCX for unknown ZIP + return FileTypeInfo{ + MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Extension: "docx", + } + } + + // Unknown file type + return FileTypeInfo{MimeType: "application/octet-stream", Extension: "bin"} +} + // HTTPClient handles HTTP requests to the TurboDocx API type HTTPClient struct { client *http.Client @@ -29,7 +85,7 @@ func NewHTTPClient(config ClientConfig) *HTTPClient { } } -// TurboDocxError represents an API error +// TurboDocxError represents a base API error type TurboDocxError struct { Message string `json:"message"` StatusCode int `json:"statusCode"` @@ -43,15 +99,46 @@ func (e *TurboDocxError) Error() string { return fmt.Sprintf("TurboDocx API error: %s (status %d)", e.Message, e.StatusCode) } +// AuthenticationError is raised when authentication fails (HTTP 401) +type AuthenticationError struct { + TurboDocxError +} + +// ValidationError is raised when validation fails (HTTP 400) +type ValidationError struct { + TurboDocxError +} + +// NotFoundError is raised when resource is not found (HTTP 404) +type NotFoundError struct { + TurboDocxError +} + +// RateLimitError is raised when rate limit is exceeded (HTTP 429) +type RateLimitError struct { + TurboDocxError +} + +// NetworkError is raised when network request fails +type NetworkError struct { + TurboDocxError +} + func (c *HTTPClient) setHeaders(req *http.Request, contentType string) { if contentType != "" { req.Header.Set("Content-Type", contentType) } + // API key is sent as Bearer token (backend expects Authorization header) if c.config.AccessToken != "" { req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) } else if c.config.APIKey != "" { - req.Header.Set("X-API-Key", c.config.APIKey) + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + // Organization ID header (required by backend) + if c.config.OrgID != "" { + req.Header.Set("x-rapiddocx-org-id", c.config.OrgID) } } @@ -60,7 +147,10 @@ func (c *HTTPClient) handleResponse(resp *http.Response, result interface{}) err body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to read response body: %v", err), + StatusCode: 0, + }} } if resp.StatusCode >= 400 { @@ -77,19 +167,58 @@ func (c *HTTPClient) handleResponse(resp *http.Response, result interface{}) err if msg == "" { msg = resp.Status } - return &TurboDocxError{ + baseErr := TurboDocxError{ Message: msg, StatusCode: resp.StatusCode, Code: apiErr.Code, } + + switch resp.StatusCode { + case 400: + return &ValidationError{TurboDocxError: baseErr} + case 401: + return &AuthenticationError{TurboDocxError: baseErr} + case 404: + return &NotFoundError{TurboDocxError: baseErr} + case 429: + return &RateLimitError{TurboDocxError: baseErr} + default: + return &baseErr + } } - return &TurboDocxError{ + baseErr := TurboDocxError{ Message: resp.Status, StatusCode: resp.StatusCode, } + switch resp.StatusCode { + case 400: + return &ValidationError{TurboDocxError: baseErr} + case 401: + return &AuthenticationError{TurboDocxError: baseErr} + case 404: + return &NotFoundError{TurboDocxError: baseErr} + case 429: + return &RateLimitError{TurboDocxError: baseErr} + default: + return &baseErr + } } if result != nil { + // Smart unwrapping: if response has ONLY "data" key, extract it + // This handles backend responses that wrap data in { "data": { ... } } + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + // If only "data" key exists, unwrap it + if data, ok := wrapper["data"]; ok && len(wrapper) == 1 { + if err := json.Unmarshal(data, result); err != nil { + return fmt.Errorf("failed to decode unwrapped response: %w", err) + } + return nil + } + } + + // Otherwise unmarshal directly if err := json.Unmarshal(body, result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } @@ -102,14 +231,18 @@ func (c *HTTPClient) handleResponse(resp *http.Response, result interface{}) err func (c *HTTPClient) Get(ctx context.Context, path string, result interface{}) error { req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to create request: %v", err), + }} } c.setHeaders(req, "application/json") resp, err := c.client.Do(req) if err != nil { - return fmt.Errorf("request failed: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("request failed: %v", err), + }} } return c.handleResponse(resp, result) @@ -119,22 +252,38 @@ func (c *HTTPClient) Get(ctx context.Context, path string, result interface{}) e func (c *HTTPClient) GetRaw(ctx context.Context, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return nil, &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to create request: %v", err), + }} } c.setHeaders(req, "") resp, err := c.client.Do(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("request failed: %v", err), + }} } defer resp.Body.Close() if resp.StatusCode >= 400 { - return nil, &TurboDocxError{ + baseErr := TurboDocxError{ Message: resp.Status, StatusCode: resp.StatusCode, } + switch resp.StatusCode { + case 400: + return nil, &ValidationError{TurboDocxError: baseErr} + case 401: + return nil, &AuthenticationError{TurboDocxError: baseErr} + case 404: + return nil, &NotFoundError{TurboDocxError: baseErr} + case 429: + return nil, &RateLimitError{TurboDocxError: baseErr} + default: + return nil, &baseErr + } } return io.ReadAll(resp.Body) @@ -153,30 +302,70 @@ func (c *HTTPClient) Post(ctx context.Context, path string, data interface{}, re req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to create request: %v", err), + }} } c.setHeaders(req, "application/json") resp, err := c.client.Do(req) if err != nil { - return fmt.Errorf("request failed: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("request failed: %v", err), + }} } return c.handleResponse(resp, result) } // UploadFile performs a multipart file upload -func (c *HTTPClient) UploadFile(ctx context.Context, path string, file []byte, fileName string, additionalData map[string]string, result interface{}) error { +// file can be either a file path (string) or file content ([]byte) +func (c *HTTPClient) UploadFile(ctx context.Context, path string, file interface{}, fileName string, additionalData map[string]string, result interface{}) error { + var fileBytes []byte + var err error + + // Handle file path vs bytes + switch f := file.(type) { + case string: + // File path - read from disk + fileBytes, err = os.ReadFile(f) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + if fileName == "" { + fileName = filepath.Base(f) + } + case []byte: + // Bytes - use directly + fileBytes = f + if fileName == "" { + // Detect extension from content + detected := DetectFileType(fileBytes) + fileName = "document." + detected.Extension + } + default: + return fmt.Errorf("file must be a file path (string) or file content ([]byte)") + } + + // Detect MIME type from file content + detected := DetectFileType(fileBytes) + mimeType := detected.MimeType + var buf bytes.Buffer writer := multipart.NewWriter(&buf) - // Add file - part, err := writer.CreateFormFile("file", fileName) + // Create form file part with correct MIME type + // Using CreatePart instead of CreateFormFile to set proper Content-Type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, fileName)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) if err != nil { return fmt.Errorf("failed to create form file: %w", err) } - if _, err := part.Write(file); err != nil { + if _, err := part.Write(fileBytes); err != nil { return fmt.Errorf("failed to write file data: %w", err) } @@ -193,7 +382,9 @@ func (c *HTTPClient) UploadFile(ctx context.Context, path string, file []byte, f req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, &buf) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to create request: %v", err), + }} } c.setHeaders(req, "") @@ -201,8 +392,20 @@ func (c *HTTPClient) UploadFile(ctx context.Context, path string, file []byte, f resp, err := c.client.Do(req) if err != nil { - return fmt.Errorf("request failed: %w", err) + return &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("request failed: %v", err), + }} } return c.handleResponse(resp, result) } + +// UploadFileBytes is a convenience method for uploading file bytes +func (c *HTTPClient) UploadFileBytes(ctx context.Context, path string, fileBytes []byte, fileName string, additionalData map[string]string, result interface{}) error { + return c.UploadFile(ctx, path, fileBytes, fileName, additionalData, result) +} + +// UploadFilePath is a convenience method for uploading a file from disk +func (c *HTTPClient) UploadFilePath(ctx context.Context, path string, filePath string, additionalData map[string]string, result interface{}) error { + return c.UploadFile(ctx, path, filePath, "", additionalData, result) +} diff --git a/packages/go-sdk/turbodocx.go b/packages/go-sdk/turbodocx.go index d8c4bc7a..a73b9773 100644 --- a/packages/go-sdk/turbodocx.go +++ b/packages/go-sdk/turbodocx.go @@ -4,20 +4,25 @@ // // Example usage: // -// client := turbodocx.NewClient("your-api-key") +// client, err := turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ +// APIKey: "your-api-key", +// OrgID: "your-org-id", +// }) // // // Prepare document for signing -// result, err := client.TurboSign.PrepareForSigningSingle(ctx, &turbodocx.PrepareForSigningRequest{ +// result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ // FileLink: "https://example.com/contract.pdf", // Recipients: []turbodocx.Recipient{ -// {Name: "John Doe", Email: "john@example.com", Order: 1}, +// {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, // }, // Fields: []turbodocx.Field{ -// {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, +// {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, // }, // }) package turbodocx +import "errors" + // Version is the current SDK version const Version = "0.1.0" @@ -34,6 +39,9 @@ type ClientConfig struct { // APIKey is your TurboDocx API key APIKey string + // OrgID is your Organization ID (required for authentication) + OrgID string + // AccessToken is an OAuth2 access token (alternative to APIKey) AccessToken string @@ -41,21 +49,37 @@ type ClientConfig struct { BaseURL string } -// NewClient creates a new TurboDocx client with the given API key -func NewClient(apiKey string) *Client { - return NewClientWithConfig(ClientConfig{APIKey: apiKey}) +// NewClient creates a new TurboDocx client with the given API key and org ID +func NewClient(apiKey, orgID string) (*Client, error) { + return NewClientWithConfig(ClientConfig{ + APIKey: apiKey, + OrgID: orgID, + }) } // NewClientWithConfig creates a new TurboDocx client with custom configuration -func NewClientWithConfig(config ClientConfig) *Client { +func NewClientWithConfig(config ClientConfig) (*Client, error) { if config.BaseURL == "" { config.BaseURL = "https://api.turbodocx.com" } + if config.APIKey == "" && config.AccessToken == "" { + return nil, errors.New("API key or access token is required") + } + + if config.OrgID == "" { + return nil, &AuthenticationError{ + TurboDocxError: TurboDocxError{ + Message: "Organization ID (OrgID) is required for authentication", + StatusCode: 401, + }, + } + } + httpClient := NewHTTPClient(config) return &Client{ TurboSign: NewTurboSignClient(httpClient), httpClient: httpClient, - } + }, nil } diff --git a/packages/go-sdk/turbosign.go b/packages/go-sdk/turbosign.go index 02c63197..76a3e29a 100644 --- a/packages/go-sdk/turbosign.go +++ b/packages/go-sdk/turbosign.go @@ -3,11 +3,12 @@ package turbodocx import ( "context" "encoding/json" - "strings" + "fmt" + "io" + "net/http" ) // TurboSignClient provides digital signature operations -// with 100% parity with n8n-nodes-turbodocx type TurboSignClient struct { http *HTTPClient } @@ -23,24 +24,53 @@ func NewTurboSignClient(http *HTTPClient) *TurboSignClient { // Recipient represents a document recipient type Recipient struct { - Name string `json:"name"` - Email string `json:"email"` - Order int `json:"order"` + Name string `json:"name"` + Email string `json:"email"` + SigningOrder int `json:"signingOrder"` +} + +// TemplateAnchor represents template anchor configuration for dynamic field positioning +type TemplateAnchor struct { + Anchor string `json:"anchor,omitempty"` + SearchText string `json:"searchText,omitempty"` + Placement string `json:"placement,omitempty"` // replace, before, after, above, below + Size *Size `json:"size,omitempty"` + Offset *Point `json:"offset,omitempty"` + CaseSensitive bool `json:"caseSensitive,omitempty"` + UseRegex bool `json:"useRegex,omitempty"` +} + +// Size represents width and height +type Size struct { + Width int `json:"width"` + Height int `json:"height"` +} + +// Point represents x and y coordinates +type Point struct { + X int `json:"x"` + Y int `json:"y"` } // Field represents a signature field type Field struct { - Type string `json:"type"` - Page int `json:"page,omitempty"` - X int `json:"x,omitempty"` - Y int `json:"y,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - RecipientOrder int `json:"recipientOrder"` + Type string `json:"type"` + Page int `json:"page,omitempty"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + RecipientEmail string `json:"recipientEmail"` + DefaultValue string `json:"defaultValue,omitempty"` + IsMultiline bool `json:"isMultiline,omitempty"` + IsReadonly bool `json:"isReadonly,omitempty"` + Required bool `json:"required,omitempty"` + BackgroundColor string `json:"backgroundColor,omitempty"` + Template *TemplateAnchor `json:"template,omitempty"` } -// PrepareForReviewRequest is the request for PrepareForReview -type PrepareForReviewRequest struct { +// CreateSignatureReviewLinkRequest is the request for CreateSignatureReviewLink +type CreateSignatureReviewLinkRequest struct { // File content (use this OR FileLink/DeliverableID/TemplateID) File []byte FileName string @@ -62,16 +92,18 @@ type PrepareForReviewRequest struct { CCEmails []string } -// PrepareForReviewResponse is the response from PrepareForReview -type PrepareForReviewResponse struct { - DocumentID string `json:"documentId"` - Status string `json:"status"` - PreviewURL string `json:"previewUrl,omitempty"` - Recipients []RecipientStatusResponse `json:"recipients,omitempty"` +// CreateSignatureReviewLinkResponse is the response from CreateSignatureReviewLink +type CreateSignatureReviewLinkResponse struct { + Success bool `json:"success"` + DocumentID string `json:"documentId"` + Status string `json:"status"` + PreviewURL string `json:"previewUrl,omitempty"` + Message string `json:"message"` + Recipients []RecipientResponse `json:"recipients,omitempty"` } -// PrepareForSigningRequest is the request for PrepareForSigningSingle -type PrepareForSigningRequest struct { +// SendSignatureRequest is the request for SendSignature +type SendSignatureRequest struct { // File content (use this OR FileLink/DeliverableID/TemplateID) File []byte FileName string @@ -93,39 +125,32 @@ type PrepareForSigningRequest struct { CCEmails []string } -// PrepareForSigningResponse is the response from PrepareForSigningSingle -type PrepareForSigningResponse struct { - DocumentID string `json:"documentId"` - Status string `json:"status"` - Recipients []RecipientSignResponse `json:"recipients"` -} - -// RecipientStatusResponse represents a recipient's status -type RecipientStatusResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Status string `json:"status"` +// SendSignatureResponse is the response from SendSignature +type SendSignatureResponse struct { + Success bool `json:"success"` + DocumentID string `json:"documentId"` + Message string `json:"message"` } -// RecipientSignResponse represents a recipient with sign URL -type RecipientSignResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Status string `json:"status"` - SignURL string `json:"signUrl,omitempty"` +// RecipientResponse represents a recipient's status +type RecipientResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Status string `json:"status"` + SignURL string `json:"signUrl,omitempty"` + SignedAt string `json:"signedAt,omitempty"` } // DocumentStatusResponse is the response from GetStatus type DocumentStatusResponse struct { - DocumentID string `json:"documentId"` - Status string `json:"status"` - Name string `json:"name"` - Recipients []RecipientStatusResponse `json:"recipients"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CompletedAt string `json:"completedAt,omitempty"` + DocumentID string `json:"documentId"` + Status string `json:"status"` + Name string `json:"name"` + Recipients []RecipientResponse `json:"recipients"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` } // VoidDocumentResponse is the response from VoidDocument @@ -142,13 +167,34 @@ type ResendEmailResponse struct { ResentAt string `json:"resentAt"` } +// DownloadResponse is the API response for download request +type DownloadResponse struct { + DownloadURL string `json:"downloadUrl"` + FileName string `json:"fileName"` +} + +// AuditTrailEntry represents a single audit trail entry +type AuditTrailEntry struct { + Event string `json:"event"` + Actor string `json:"actor"` + Timestamp string `json:"timestamp"` + IPAddress string `json:"ipAddress,omitempty"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// AuditTrailResponse is the response from GetAuditTrail +type AuditTrailResponse struct { + DocumentID string `json:"documentId"` + Entries []AuditTrailEntry `json:"entries"` +} + // ============================================ -// N8N PARITY METHODS +// TurboSign Methods // ============================================ -// PrepareForReview prepares a document for review without sending emails. +// CreateSignatureReviewLink prepares a document for review without sending emails. // Use this to preview field placement before sending. -func (c *TurboSignClient) PrepareForReview(ctx context.Context, req *PrepareForReviewRequest) (*PrepareForReviewResponse, error) { +func (c *TurboSignClient) CreateSignatureReviewLink(ctx context.Context, req *CreateSignatureReviewLinkRequest) (*CreateSignatureReviewLinkResponse, error) { recipientsJSON, _ := json.Marshal(req.Recipients) fieldsJSON, _ := json.Marshal(req.Fields) @@ -170,12 +216,11 @@ func (c *TurboSignClient) PrepareForReview(ctx context.Context, req *PrepareForR formData["senderEmail"] = req.SenderEmail } if len(req.CCEmails) > 0 { - formData["ccEmails"] = strings.Join(req.CCEmails, ",") + ccEmailsJSON, _ := json.Marshal(req.CCEmails) + formData["ccEmails"] = string(ccEmailsJSON) } - var response struct { - Data PrepareForReviewResponse `json:"data"` - } + var response CreateSignatureReviewLinkResponse if len(req.File) > 0 { fileName := req.FileName @@ -203,12 +248,11 @@ func (c *TurboSignClient) PrepareForReview(ctx context.Context, req *PrepareForR } } - return &response.Data, nil + return &response, nil } -// PrepareForSigningSingle prepares a document for signing and sends emails in a single call. -// This is the n8n-equivalent "Prepare for Signing" operation. -func (c *TurboSignClient) PrepareForSigningSingle(ctx context.Context, req *PrepareForSigningRequest) (*PrepareForSigningResponse, error) { +// SendSignature prepares a document for signing and sends emails in a single call. +func (c *TurboSignClient) SendSignature(ctx context.Context, req *SendSignatureRequest) (*SendSignatureResponse, error) { recipientsJSON, _ := json.Marshal(req.Recipients) fieldsJSON, _ := json.Marshal(req.Fields) @@ -230,12 +274,11 @@ func (c *TurboSignClient) PrepareForSigningSingle(ctx context.Context, req *Prep formData["senderEmail"] = req.SenderEmail } if len(req.CCEmails) > 0 { - formData["ccEmails"] = strings.Join(req.CCEmails, ",") + ccEmailsJSON, _ := json.Marshal(req.CCEmails) + formData["ccEmails"] = string(ccEmailsJSON) } - var response struct { - Data PrepareForSigningResponse `json:"data"` - } + var response SendSignatureResponse if len(req.File) > 0 { fileName := req.FileName @@ -263,52 +306,86 @@ func (c *TurboSignClient) PrepareForSigningSingle(ctx context.Context, req *Prep } } - return &response.Data, nil + return &response, nil } // GetStatus gets the status of a document func (c *TurboSignClient) GetStatus(ctx context.Context, documentID string) (*DocumentStatusResponse, error) { - var response struct { - Data DocumentStatusResponse `json:"data"` - } + var response DocumentStatusResponse err := c.http.Get(ctx, "/turbosign/documents/"+documentID+"/status", &response) if err != nil { return nil, err } - return &response.Data, nil + return &response, nil } -// Download downloads the signed document as bytes +// Download downloads the signed document as bytes. +// The backend returns a presigned S3 URL, which this method fetches. func (c *TurboSignClient) Download(ctx context.Context, documentID string) ([]byte, error) { - return c.http.GetRaw(ctx, "/turbosign/documents/"+documentID+"/download") + // Get presigned URL from API + var downloadResponse DownloadResponse + err := c.http.Get(ctx, "/turbosign/documents/"+documentID+"/download", &downloadResponse) + if err != nil { + return nil, err + } + + if downloadResponse.DownloadURL == "" { + return nil, fmt.Errorf("no download URL in response") + } + + // Fetch actual file from S3 + resp, err := http.Get(downloadResponse.DownloadURL) + if err != nil { + return nil, &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to download file: %v", err), + }} + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, &NetworkError{TurboDocxError: TurboDocxError{ + Message: fmt.Sprintf("failed to download file: %s", resp.Status), + StatusCode: resp.StatusCode, + }} + } + + return io.ReadAll(resp.Body) } // VoidDocument voids a document (cancels signature request) func (c *TurboSignClient) VoidDocument(ctx context.Context, documentID string, reason string) (*VoidDocumentResponse, error) { - var response struct { - Data VoidDocumentResponse `json:"data"` - } + var response VoidDocumentResponse err := c.http.Post(ctx, "/turbosign/documents/"+documentID+"/void", map[string]string{"reason": reason}, &response) if err != nil { return nil, err } - return &response.Data, nil + return &response, nil } // ResendEmail resends signature request email to recipients func (c *TurboSignClient) ResendEmail(ctx context.Context, documentID string, recipientIDs []string) (*ResendEmailResponse, error) { - var response struct { - Data ResendEmailResponse `json:"data"` - } + var response ResendEmailResponse err := c.http.Post(ctx, "/turbosign/documents/"+documentID+"/resend-email", map[string][]string{"recipientIds": recipientIDs}, &response) if err != nil { return nil, err } - return &response.Data, nil + return &response, nil +} + +// GetAuditTrail gets the audit trail for a document +func (c *TurboSignClient) GetAuditTrail(ctx context.Context, documentID string) (*AuditTrailResponse, error) { + var response AuditTrailResponse + + err := c.http.Get(ctx, "/turbosign/documents/"+documentID+"/audit-trail", &response) + if err != nil { + return nil, err + } + + return &response, nil } diff --git a/packages/go-sdk/turbosign_test.go b/packages/go-sdk/turbosign_test.go index 65a4ed4d..20cec596 100644 --- a/packages/go-sdk/turbosign_test.go +++ b/packages/go-sdk/turbosign_test.go @@ -12,62 +12,77 @@ import ( ) func TestClient_Configure(t *testing.T) { - t.Run("with API key", func(t *testing.T) { - client := NewClient("test-api-key") + t.Run("with API key and org ID", func(t *testing.T) { + client, err := NewClient("test-api-key", "test-org-id") + require.NoError(t, err) assert.NotNil(t, client) assert.NotNil(t, client.TurboSign) }) t.Run("with custom base URL", func(t *testing.T) { - client := NewClientWithConfig(ClientConfig{ + client, err := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: "https://custom-api.example.com", }) + require.NoError(t, err) assert.NotNil(t, client) }) - t.Run("with access token", func(t *testing.T) { - client := NewClientWithConfig(ClientConfig{ - AccessToken: "test-access-token", + t.Run("requires org ID", func(t *testing.T) { + _, err := NewClientWithConfig(ClientConfig{ + APIKey: "test-api-key", }) - assert.NotNil(t, client) + require.Error(t, err) + _, ok := err.(*AuthenticationError) + assert.True(t, ok, "expected AuthenticationError") + }) + + t.Run("requires API key or access token", func(t *testing.T) { + _, err := NewClientWithConfig(ClientConfig{ + OrgID: "test-org-id", + }) + require.Error(t, err) }) } -func TestTurboSignClient_PrepareForReview(t *testing.T) { +func TestTurboSignClient_CreateSignatureReviewLink(t *testing.T) { t.Run("with file URL", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/turbosign/single/prepare-for-review", r.URL.Path) assert.Equal(t, "POST", r.Method) - assert.Equal(t, "test-api-key", r.Header.Get("X-API-Key")) + assert.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization")) + assert.Equal(t, "test-org-id", r.Header.Get("x-rapiddocx-org-id")) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-123", - "status": "review_ready", - "previewUrl": "https://preview.example.com/doc-123", - }, + "success": true, + "documentId": "doc-123", + "status": "review_ready", + "previewUrl": "https://preview.example.com/doc-123", + "message": "Document prepared for review", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForReview(context.Background(), &PrepareForReviewRequest{ + result, err := client.TurboSign.CreateSignatureReviewLink(context.Background(), &CreateSignatureReviewLinkRequest{ FileLink: "https://storage.example.com/contract.pdf", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) require.NoError(t, err) + assert.True(t, result.Success) assert.Equal(t, "doc-123", result.DocumentID) assert.Equal(t, "review_ready", result.Status) assert.Equal(t, "https://preview.example.com/doc-123", result.PreviewURL) @@ -77,26 +92,27 @@ func TestTurboSignClient_PrepareForReview(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-456", - "status": "review_ready", - }, + "success": true, + "documentId": "doc-456", + "status": "review_ready", + "message": "Document prepared for review", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForReview(context.Background(), &PrepareForReviewRequest{ + result, err := client.TurboSign.CreateSignatureReviewLink(context.Background(), &CreateSignatureReviewLinkRequest{ DeliverableID: "deliverable-abc", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) @@ -104,66 +120,31 @@ func TestTurboSignClient_PrepareForReview(t *testing.T) { assert.Equal(t, "doc-456", result.DocumentID) }) - t.Run("with optional fields", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-789", - "status": "review_ready", - }, - }) - })) - defer server.Close() - - client := NewClientWithConfig(ClientConfig{ - APIKey: "test-api-key", - BaseURL: server.URL, - }) - - result, err := client.TurboSign.PrepareForReview(context.Background(), &PrepareForReviewRequest{ - FileLink: "https://example.com/doc.pdf", - Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, - }, - Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, - }, - DocumentName: "Test Contract", - DocumentDescription: "A test contract", - SenderName: "Sales Team", - SenderEmail: "sales@company.com", - CCEmails: []string{"admin@company.com", "legal@company.com"}, - }) - - require.NoError(t, err) - assert.Equal(t, "doc-789", result.DocumentID) - }) - t.Run("with template ID", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-template", - "status": "review_ready", - }, + "success": true, + "documentId": "doc-template", + "status": "review_ready", + "message": "Document prepared for review", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForReview(context.Background(), &PrepareForReviewRequest{ + result, err := client.TurboSign.CreateSignatureReviewLink(context.Background(), &CreateSignatureReviewLinkRequest{ TemplateID: "template-xyz", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) @@ -176,27 +157,28 @@ func TestTurboSignClient_PrepareForReview(t *testing.T) { assert.Contains(t, r.Header.Get("Content-Type"), "multipart/form-data") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-upload", - "status": "review_ready", - }, + "success": true, + "documentId": "doc-upload", + "status": "review_ready", + "message": "Document prepared for review", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForReview(context.Background(), &PrepareForReviewRequest{ + result, err := client.TurboSign.CreateSignatureReviewLink(context.Background(), &CreateSignatureReviewLinkRequest{ File: []byte("%PDF-mock-content"), FileName: "contract.pdf", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) @@ -205,50 +187,39 @@ func TestTurboSignClient_PrepareForReview(t *testing.T) { }) } -func TestTurboSignClient_PrepareForSigningSingle(t *testing.T) { +func TestTurboSignClient_SendSignature(t *testing.T) { t.Run("should prepare and send", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/turbosign/single/prepare-for-signing", r.URL.Path) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-123", - "status": "sent", - "recipients": []map[string]interface{}{ - { - "id": "rec-1", - "name": "John Doe", - "email": "john@example.com", - "status": "pending", - "signUrl": "https://sign.example.com/rec-1", - }, - }, - }, + "success": true, + "documentId": "doc-123", + "message": "Document sent for signing", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForSigningSingle(context.Background(), &PrepareForSigningRequest{ + result, err := client.TurboSign.SendSignature(context.Background(), &SendSignatureRequest{ FileLink: "https://storage.example.com/contract.pdf", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) require.NoError(t, err) + assert.True(t, result.Success) assert.Equal(t, "doc-123", result.DocumentID) - assert.Equal(t, "sent", result.Status) - assert.Len(t, result.Recipients, 1) - assert.Equal(t, "https://sign.example.com/rec-1", result.Recipients[0].SignURL) }) t.Run("with file upload", func(t *testing.T) { @@ -256,27 +227,27 @@ func TestTurboSignClient_PrepareForSigningSingle(t *testing.T) { assert.Contains(t, r.Header.Get("Content-Type"), "multipart/form-data") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-upload", - "status": "sent", - }, + "success": true, + "documentId": "doc-upload", + "message": "Document sent for signing", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - result, err := client.TurboSign.PrepareForSigningSingle(context.Background(), &PrepareForSigningRequest{ + result, err := client.TurboSign.SendSignature(context.Background(), &SendSignatureRequest{ File: []byte("%PDF-mock-content"), FileName: "contract.pdf", Recipients: []Recipient{ - {Name: "John Doe", Email: "john@example.com", Order: 1}, + {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []Field{ - {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientOrder: 1}, + {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) @@ -292,27 +263,26 @@ func TestTurboSignClient_GetStatus(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-123", - "status": "pending", - "name": "Test Document", - "recipients": []map[string]interface{}{ - { - "id": "rec-1", - "name": "John Doe", - "email": "john@example.com", - "status": "pending", - }, + "documentId": "doc-123", + "status": "pending", + "name": "Test Document", + "recipients": []map[string]interface{}{ + { + "id": "rec-1", + "name": "John Doe", + "email": "john@example.com", + "status": "pending", }, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) @@ -326,15 +296,30 @@ func TestTurboSignClient_GetStatus(t *testing.T) { func TestTurboSignClient_Download(t *testing.T) { expectedContent := []byte("%PDF-mock-content") + presignedURL := "" + // S3 server + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(expectedContent) + })) + defer s3Server.Close() + + presignedURL = s3Server.URL + "/signed-doc.pdf" + + // API server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/turbosign/documents/doc-123/download", r.URL.Path) - w.Write(expectedContent) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "downloadUrl": presignedURL, + "fileName": "signed-document.pdf", + }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) @@ -351,17 +336,16 @@ func TestTurboSignClient_VoidDocument(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-123", - "status": "voided", - "voidedAt": "2024-01-01T12:00:00Z", - }, + "documentId": "doc-123", + "status": "voided", + "voidedAt": "2024-01-01T12:00:00Z", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) @@ -378,17 +362,16 @@ func TestTurboSignClient_ResendEmail(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "data": map[string]interface{}{ - "documentId": "doc-123", - "message": "Emails resent successfully", - "resentAt": "2024-01-01T12:00:00Z", - }, + "documentId": "doc-123", + "message": "Emails resent successfully", + "resentAt": "2024-01-01T12:00:00Z", }) })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) @@ -398,8 +381,50 @@ func TestTurboSignClient_ResendEmail(t *testing.T) { assert.Contains(t, result.Message, "resent") } +func TestTurboSignClient_GetAuditTrail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/turbosign/documents/doc-123/audit-trail", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "documentId": "doc-123", + "entries": []map[string]interface{}{ + { + "event": "document_created", + "actor": "user@example.com", + "timestamp": "2024-01-01T00:00:00Z", + "ipAddress": "192.168.1.1", + }, + { + "event": "email_sent", + "actor": "system", + "timestamp": "2024-01-01T00:01:00Z", + "details": map[string]interface{}{ + "recipientEmail": "signer@example.com", + }, + }, + }, + }) + })) + defer server.Close() + + client, _ := NewClientWithConfig(ClientConfig{ + APIKey: "test-api-key", + OrgID: "test-org-id", + BaseURL: server.URL, + }) + + result, err := client.TurboSign.GetAuditTrail(context.Background(), "doc-123") + + require.NoError(t, err) + assert.Equal(t, "doc-123", result.DocumentID) + assert.Len(t, result.Entries, 2) + assert.Equal(t, "document_created", result.Entries[0].Event) +} + func TestClient_ErrorHandling(t *testing.T) { - t.Run("API error", func(t *testing.T) { + t.Run("not found error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) @@ -410,19 +435,19 @@ func TestClient_ErrorHandling(t *testing.T) { })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) _, err := client.TurboSign.GetStatus(context.Background(), "invalid-doc") require.Error(t, err) - apiErr, ok := err.(*TurboDocxError) - require.True(t, ok) - assert.Equal(t, 404, apiErr.StatusCode) - assert.Equal(t, "Document not found", apiErr.Message) - assert.Equal(t, "DOCUMENT_NOT_FOUND", apiErr.Code) + notFoundErr, ok := err.(*NotFoundError) + require.True(t, ok, "expected NotFoundError") + assert.Equal(t, 404, notFoundErr.StatusCode) + assert.Equal(t, "Document not found", notFoundErr.Message) }) t.Run("authentication error", func(t *testing.T) { @@ -434,17 +459,18 @@ func TestClient_ErrorHandling(t *testing.T) { })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "invalid-key", + OrgID: "test-org-id", BaseURL: server.URL, }) _, err := client.TurboSign.GetStatus(context.Background(), "doc-123") require.Error(t, err) - apiErr, ok := err.(*TurboDocxError) - require.True(t, ok) - assert.Equal(t, 401, apiErr.StatusCode) + authErr, ok := err.(*AuthenticationError) + require.True(t, ok, "expected AuthenticationError") + assert.Equal(t, 401, authErr.StatusCode) }) t.Run("validation error", func(t *testing.T) { @@ -458,23 +484,49 @@ func TestClient_ErrorHandling(t *testing.T) { })) defer server.Close() - client := NewClientWithConfig(ClientConfig{ + client, _ := NewClientWithConfig(ClientConfig{ APIKey: "test-api-key", + OrgID: "test-org-id", BaseURL: server.URL, }) - _, err := client.TurboSign.PrepareForSigningSingle(context.Background(), &PrepareForSigningRequest{ + _, err := client.TurboSign.SendSignature(context.Background(), &SendSignatureRequest{ FileLink: "https://example.com/doc.pdf", Recipients: []Recipient{ - {Name: "Test", Email: "invalid-email", Order: 1}, + {Name: "Test", Email: "invalid-email", SigningOrder: 1}, }, Fields: []Field{}, }) require.Error(t, err) - apiErr, ok := err.(*TurboDocxError) - require.True(t, ok) - assert.Equal(t, 400, apiErr.StatusCode) - assert.Contains(t, apiErr.Message, "Validation") + validationErr, ok := err.(*ValidationError) + require.True(t, ok, "expected ValidationError") + assert.Equal(t, 400, validationErr.StatusCode) + assert.Contains(t, validationErr.Message, "Validation") + }) + + t.Run("rate limit error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Rate limit exceeded", + "code": "RATE_LIMIT_EXCEEDED", + }) + })) + defer server.Close() + + client, _ := NewClientWithConfig(ClientConfig{ + APIKey: "test-api-key", + OrgID: "test-org-id", + BaseURL: server.URL, + }) + + _, err := client.TurboSign.GetStatus(context.Background(), "doc-123") + + require.Error(t, err) + rateLimitErr, ok := err.(*RateLimitError) + require.True(t, ok, "expected RateLimitError") + assert.Equal(t, 429, rateLimitErr.StatusCode) }) } diff --git a/packages/java-sdk/README.md b/packages/java-sdk/README.md index fbd23a0f..01de4339 100644 --- a/packages/java-sdk/README.md +++ b/packages/java-sdk/README.md @@ -322,6 +322,51 @@ public class ContractController { --- +## Local Testing + +The SDK includes a comprehensive manual test class to verify all functionality locally. + +### Running Manual Tests + +```bash +# Using Maven +mvn exec:java -Dexec.mainClass="com.turbodocx.ManualTest" + +# Or compile and run directly +mvn clean compile +java -cp target/classes:$(mvn dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q) com.turbodocx.ManualTest +``` + +### What It Tests + +The `ManualTest.java` class tests all SDK methods: +- ✅ `prepareForReview()` - Document upload for review +- ✅ `prepareForSigningSingle()` - Send for signature +- ✅ `getStatus()` - Check document status +- ✅ `download()` - Download signed document +- ✅ `voidDocument()` - Cancel signature request +- ✅ `resend()` - Resend signature emails + +### Configuration + +Before running, update the hardcoded values in `src/main/java/com/turbodocx/ManualTest.java`: +- `API_KEY` - Your TurboDocx API key +- `BASE_URL` - API endpoint (default: `http://localhost:3000`) +- `ORG_ID` - Your organization UUID +- `TEST_FILE_PATH` - Path to a test PDF/DOCX file +- `TEST_EMAIL` - Email address for testing + +### Expected Output + +The test class will: +1. Upload a test document +2. Send it for signature +3. Check the status +4. Test void and resend operations +5. Print results for each operation + +--- + ## Error Handling ```java diff --git a/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java b/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java index 195444fb..14b4d524 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java @@ -5,8 +5,26 @@ import okhttp3.*; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; import java.util.Map; +/** + * File type detection result + */ +class FileTypeInfo { + public final String mimeType; + public final String extension; + + public FileTypeInfo(String mimeType, String extension) { + this.mimeType = mimeType; + this.extension = extension; + } +} + /** * HTTP client wrapper for TurboDocx API */ @@ -14,17 +32,64 @@ public class HttpClient { private static final String DEFAULT_BASE_URL = "https://api.turbodocx.com"; private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + /** + * Detect file type from magic bytes + */ + public static FileTypeInfo detectFileType(byte[] fileBytes) { + if (fileBytes == null || fileBytes.length < 4) { + return new FileTypeInfo("application/octet-stream", "bin"); + } + + // PDF: %PDF (0x25 0x50 0x44 0x46) + if (fileBytes[0] == 0x25 && fileBytes[1] == 0x50 && fileBytes[2] == 0x44 && fileBytes[3] == 0x46) { + return new FileTypeInfo("application/pdf", "pdf"); + } + + // ZIP-based formats (DOCX, PPTX): starts with PK (0x50 0x4B) + if (fileBytes[0] == 0x50 && fileBytes[1] == 0x4B) { + int headerLen = Math.min(fileBytes.length, 2000); + String header = new String(Arrays.copyOf(fileBytes, headerLen), StandardCharsets.UTF_8); + + // PPTX contains 'ppt/' in the ZIP structure + if (header.contains("ppt/")) { + return new FileTypeInfo( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pptx" + ); + } + + // DOCX contains 'word/' in the ZIP structure + if (header.contains("word/")) { + return new FileTypeInfo( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx" + ); + } + + // Default to DOCX for unknown ZIP + return new FileTypeInfo( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx" + ); + } + + // Unknown file type + return new FileTypeInfo("application/octet-stream", "bin"); + } + private final OkHttpClient client; private final String baseUrl; private final String apiKey; private final String accessToken; + private final String orgId; private final Gson gson; - public HttpClient(String baseUrl, String apiKey, String accessToken) { + public HttpClient(String baseUrl, String apiKey, String accessToken, String orgId) { this.client = new OkHttpClient(); this.baseUrl = baseUrl != null ? baseUrl.replaceAll("/$", "") : DEFAULT_BASE_URL; this.apiKey = apiKey; this.accessToken = accessToken; + this.orgId = orgId; this.gson = new Gson(); } @@ -65,11 +130,23 @@ public T post(String path, Object body, Class responseClass) throws IOExc return execute(request, responseClass); } + /** + * Upload file from bytes + */ public T uploadFile(String path, byte[] file, String fileName, Map formData, Class responseClass) throws IOException { + // Auto-detect filename from content if not provided + if (fileName == null || fileName.isEmpty()) { + FileTypeInfo detected = detectFileType(file); + fileName = "document." + detected.extension; + } + + // Detect MIME type from content + FileTypeInfo detected = detectFileType(file); + MultipartBody.Builder builder = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("file", fileName, - RequestBody.create(file, MediaType.parse("application/octet-stream"))); + RequestBody.create(file, MediaType.parse(detected.mimeType))); for (Map.Entry entry : formData.entrySet()) { builder.addFormDataPart(entry.getKey(), entry.getValue()); @@ -84,6 +161,22 @@ public T uploadFile(String path, byte[] file, String fileName, Map T uploadFile(String path, Path filePath, Map formData, Class responseClass) throws IOException { + byte[] fileBytes = Files.readAllBytes(filePath); + String fileName = filePath.getFileName().toString(); + return uploadFile(path, fileBytes, fileName, formData, responseClass); + } + + /** + * Upload file from file path (using String path) + */ + public T uploadFilePath(String path, String filePath, Map formData, Class responseClass) throws IOException { + return uploadFile(path, Paths.get(filePath), formData, responseClass); + } + private T execute(Request request, Class responseClass) throws IOException { try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { @@ -91,6 +184,15 @@ private T execute(Request request, Class responseClass) throws IOExceptio } String responseBody = response.body() != null ? response.body().string() : ""; + + // Smart unwrapping: if response has ONLY "data" key, extract it + // This handles backend responses that wrap data in { "data": { ... } } + JsonObject json = gson.fromJson(responseBody, JsonObject.class); + if (json != null && json.has("data") && json.size() == 1) { + return gson.fromJson(json.get("data"), responseClass); + } + + // Otherwise return as-is (for direct responses) return gson.fromJson(responseBody, responseClass); } } @@ -102,28 +204,49 @@ private void handleError(Response response) throws IOException { try { JsonObject json = gson.fromJson(body, JsonObject.class); - if (json != null && json.has("message")) { - message = json.get("message").getAsString(); - } - if (json != null && json.has("code")) { - code = json.get("code").getAsString(); + if (json != null) { + // Check both "message" and "error" fields (backend uses both) + if (json.has("message")) { + message = json.get("message").getAsString(); + } else if (json.has("error")) { + message = json.get("error").getAsString(); + } + if (json.has("code")) { + code = json.get("code").getAsString(); + } } } catch (Exception e) { // Use default message } - throw new TurboDocxException(message, response.code(), code); + // Throw specific exception based on status code + switch (response.code()) { + case 400: + throw new TurboDocxException.ValidationException(message, code); + case 401: + throw new TurboDocxException.AuthenticationException(message, code); + case 404: + throw new TurboDocxException.NotFoundException(message, code); + case 429: + throw new TurboDocxException.RateLimitException(message, code); + default: + throw new TurboDocxException(message, response.code(), code); + } } private Headers buildHeaders() { Headers.Builder builder = new Headers.Builder(); - if (apiKey != null && !apiKey.isEmpty()) { - builder.add("X-API-Key", apiKey); - } - + // API key is sent as Bearer token (backend expects Authorization header) if (accessToken != null && !accessToken.isEmpty()) { builder.add("Authorization", "Bearer " + accessToken); + } else if (apiKey != null && !apiKey.isEmpty()) { + builder.add("Authorization", "Bearer " + apiKey); + } + + // Organization ID header (required by backend) + if (orgId != null && !orgId.isEmpty()) { + builder.add("x-rapiddocx-org-id", orgId); } return builder.build(); diff --git a/packages/java-sdk/src/main/java/com/turbodocx/ManualTest.java b/packages/java-sdk/src/main/java/com/turbodocx/ManualTest.java new file mode 100644 index 00000000..8df4df71 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/ManualTest.java @@ -0,0 +1,240 @@ +package com.turbodocx; + +/* + * TurboSign Java SDK - Manual Test Suite + * + * Run: mvn exec:java -Dexec.mainClass="com.turbodocx.ManualTest" + * + * Make sure to configure the values below before running. + */ + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.turbodocx.models.*; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; + +public class ManualTest { + + // ============================================= + // CONFIGURE THESE VALUES BEFORE RUNNING + // ============================================= + private static final String API_KEY = "TDX-your-api-key-here"; // Replace with your actual TurboDocx API key + private static final String BASE_URL = "http://localhost:3000"; // Replace with your API URL + private static final String ORG_ID = "your-organization-uuid-here"; // Replace with your organization UUID + + private static final String TEST_PDF_PATH = "/path/to/your/test-document.pdf"; // Replace with path to your test PDF/DOCX + private static final String TEST_EMAIL = "test-recipient@example.com"; // Replace with a real email to receive notifications + private static final String FILE_URL = "https://example.com/sample-document.pdf"; // Replace with publicly accessible PDF URL + + private static TurboDocxClient client; + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + public static void main(String[] args) { + System.out.println("=============================================="); + System.out.println("TurboSign Java SDK - Manual Test Suite"); + System.out.println("=============================================="); + + // Check if test PDF exists + if (!new File(TEST_PDF_PATH).exists()) { + System.out.println("\nError: Test PDF not found at " + TEST_PDF_PATH); + System.out.println("Please add a test PDF file and update TEST_PDF_PATH."); + System.exit(1); + } + + // Initialize client + client = new TurboDocxClient.Builder() + .apiKey(API_KEY) + .baseUrl(BASE_URL) + .orgId(ORG_ID) + .build(); + + try { + // Uncomment and run tests as needed: + + // Test 1: Prepare for Review + // String reviewDocId = testPrepareForReview(); + + // Test 2: Prepare for Signing (creates a new document) + // String signDocId = testPrepareForSigningSingle(); + + // Test 3: Get Status (replace with actual document ID) + // testGetStatus("document-uuid-here"); + + // Test 4: Download (replace with actual document ID) + // testDownload("document-uuid-here"); + + // Test 5: Resend (replace with actual document ID and recipient ID) + // testResend("document-uuid-here", Arrays.asList("recipient-uuid-here")); + + // Test 6: Void (do this last as it cancels the document) + // testVoid("document-uuid-here"); + + // Test 7: Get Audit Trail (replace with actual document ID) + // testGetAuditTrail("document-uuid-here"); + + System.out.println("\n=============================================="); + System.out.println("All tests completed successfully!"); + System.out.println("=============================================="); + + } catch (TurboDocxException e) { + System.out.println("\n=============================================="); + System.out.println("TEST FAILED"); + System.out.println("=============================================="); + System.out.println("Error: " + e.getMessage()); + System.out.println("Status Code: " + e.getStatusCode()); + if (e.getCode() != null) { + System.out.println("Error Code: " + e.getCode()); + } + System.exit(1); + } catch (Exception e) { + System.out.println("\n=============================================="); + System.out.println("TEST FAILED"); + System.out.println("=============================================="); + System.out.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + // ============================================= + // TEST FUNCTIONS + // ============================================= + + private static String testPrepareForReview() throws IOException { + System.out.println("\n--- Test 1: createSignatureReviewLink (using fileLink) ---"); + + // Using fileLink instead of file upload + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() + .fileLink(FILE_URL) + .recipients(Arrays.asList( + new Recipient("Signer One", TEST_EMAIL, 1) + )) + .fields(Arrays.asList( + new Field("signature", 1, 100, 550, 200, 50, TEST_EMAIL), + new Field("checkbox", 1, 320, 550, 50, 50, TEST_EMAIL, "true", null, null, null, null, null) + )) + .documentName("Review Test Document (fileLink)") + .build(); + + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); + System.out.println("Result: " + gson.toJson(result)); + return result.getDocumentId(); + } + + private static String testPrepareForSigningSingle() throws IOException { + System.out.println("\n--- Test 2: sendSignature (using file buffer with template fields) ---"); + + byte[] pdfBytes = Files.readAllBytes(Paths.get(TEST_PDF_PATH)); + + // Template-based field using anchor text (like Python test) + Field.TemplateAnchor templateAnchor = new Field.TemplateAnchor( + "{hello}", // anchor text to find + null, // searchText (alternative to anchor) + "replace", // placement: replace/before/after/above/below + new Field.Size(200, 80), // size + new Field.Offset(0, 0), // offset + true, // caseSensitive + false // useRegex + ); + + // Field with template anchor (no page/x/y coordinates needed) + Field templateField = new Field( + "text", // type + null, // page (null for template-based) + null, // x (null for template-based) + null, // y (null for template-based) + null, // width (null, using template size) + null, // height (null, using template size) + TEST_EMAIL, // recipientEmail + "Amit", // defaultValue + true, // isMultiline + null, // isReadonly + true, // required + null, // backgroundColor + templateAnchor // template anchor config + ); + + // Coordinate-based field (traditional approach) + Field coordinateField = new Field( + "last_name", // type + 1, // page + 100, // x + 650, // y + 200, // width + 50, // height + TEST_EMAIL, // recipientEmail + "Sharma", // defaultValue + null, // isMultiline + null, // isReadonly + null, // required + null, // backgroundColor + null // no template (coordinate-based) + ); + + SendSignatureRequest request = new SendSignatureRequest.Builder() + .file(pdfBytes) + .recipients(Arrays.asList( + new Recipient("Test User", TEST_EMAIL, 1) + )) + .fields(Arrays.asList(templateField, coordinateField)) + .documentName("Signing Test Document (Template Fields)") + .documentDescription("Testing template-based field positioning") + .senderName("Test Sender") + .senderEmail("sender@example.com") + .ccEmails(Arrays.asList("cc@example.com")) + .build(); + + SendSignatureResponse result = client.turboSign().sendSignature(request); + System.out.println("Result: " + gson.toJson(result)); + return result.getDocumentId(); + } + + private static void testGetStatus(String documentId) throws IOException { + System.out.println("\n--- Test 3: getStatus ---"); + + DocumentStatusResponse result = client.turboSign().getStatus(documentId); + System.out.println("Result: " + gson.toJson(result)); + } + + private static void testDownload(String documentId) throws IOException { + System.out.println("\n--- Test 4: download ---"); + + byte[] result = client.turboSign().download(documentId); + System.out.println("Result: PDF received, size: " + result.length + " bytes"); + + // Save to file + String outputPath = "./downloaded-document.pdf"; + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + fos.write(result); + } + System.out.println("File saved to: " + outputPath); + } + + private static void testResend(String documentId, List recipientIds) throws IOException { + System.out.println("\n--- Test 5: resendEmail ---"); + + ResendEmailResponse result = client.turboSign().resendEmail(documentId, recipientIds); + System.out.println("Result: " + gson.toJson(result)); + } + + private static void testVoid(String documentId) throws IOException { + System.out.println("\n--- Test 6: voidDocument ---"); + + VoidDocumentResponse result = client.turboSign().voidDocument(documentId, "Testing void functionality"); + System.out.println("Result: " + gson.toJson(result)); + } + + private static void testGetAuditTrail(String documentId) throws IOException { + System.out.println("\n--- Test 7: getAuditTrail ---"); + + AuditTrailResponse result = client.turboSign().getAuditTrail(documentId); + System.out.println("Result: " + gson.toJson(result)); + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxClient.java b/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxClient.java index 4699a097..8d6b2e9d 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxClient.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxClient.java @@ -7,7 +7,7 @@ public class TurboDocxClient { private final TurboSign turboSign; private TurboDocxClient(Builder builder) { - HttpClient httpClient = new HttpClient(builder.baseUrl, builder.apiKey, builder.accessToken); + HttpClient httpClient = new HttpClient(builder.baseUrl, builder.apiKey, builder.accessToken, builder.orgId); this.turboSign = new TurboSign(httpClient); } @@ -24,6 +24,7 @@ public TurboSign turboSign() { public static class Builder { private String apiKey; private String accessToken; + private String orgId; private String baseUrl; public Builder apiKey(String apiKey) { @@ -36,6 +37,14 @@ public Builder accessToken(String accessToken) { return this; } + /** + * Set the Organization ID (required for authentication) + */ + public Builder orgId(String orgId) { + this.orgId = orgId; + return this; + } + public Builder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; @@ -45,6 +54,9 @@ public TurboDocxClient build() { if ((apiKey == null || apiKey.isEmpty()) && (accessToken == null || accessToken.isEmpty())) { throw new IllegalArgumentException("API key or access token is required"); } + if (orgId == null || orgId.isEmpty()) { + throw new TurboDocxException.AuthenticationException("Organization ID (orgId) is required for authentication"); + } return new TurboDocxClient(this); } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxException.java b/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxException.java index d49d2911..e7663176 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxException.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/TurboDocxException.java @@ -1,7 +1,7 @@ package com.turbodocx; /** - * Exception thrown when TurboDocx API returns an error + * Base exception thrown when TurboDocx API returns an error */ public class TurboDocxException extends RuntimeException { private final int statusCode; @@ -17,6 +17,10 @@ public TurboDocxException(String message, int statusCode) { this(message, statusCode, null); } + public TurboDocxException(String message) { + this(message, 0, null); + } + public int getStatusCode() { return statusCode; } @@ -24,4 +28,61 @@ public int getStatusCode() { public String getCode() { return code; } + + /** + * Exception thrown when authentication fails (HTTP 401) + */ + public static class AuthenticationException extends TurboDocxException { + public AuthenticationException(String message, String code) { + super(message, 401, code); + } + public AuthenticationException(String message) { + super(message, 401, null); + } + } + + /** + * Exception thrown when validation fails (HTTP 400) + */ + public static class ValidationException extends TurboDocxException { + public ValidationException(String message, String code) { + super(message, 400, code); + } + public ValidationException(String message) { + super(message, 400, null); + } + } + + /** + * Exception thrown when resource is not found (HTTP 404) + */ + public static class NotFoundException extends TurboDocxException { + public NotFoundException(String message, String code) { + super(message, 404, code); + } + public NotFoundException(String message) { + super(message, 404, null); + } + } + + /** + * Exception thrown when rate limit is exceeded (HTTP 429) + */ + public static class RateLimitException extends TurboDocxException { + public RateLimitException(String message, String code) { + super(message, 429, code); + } + public RateLimitException(String message) { + super(message, 429, null); + } + } + + /** + * Exception thrown when a network error occurs + */ + public static class NetworkException extends TurboDocxException { + public NetworkException(String message) { + super(message, 0, null); + } + } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/TurboSign.java b/packages/java-sdk/src/main/java/com/turbodocx/TurboSign.java index 24197874..c3c9ae31 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/TurboSign.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/TurboSign.java @@ -2,31 +2,35 @@ import com.google.gson.Gson; import com.turbodocx.models.*; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * TurboSign client for digital signature operations - * with 100% parity with n8n-nodes-turbodocx + * with 100% parity with the JS SDK */ public class TurboSign { private final HttpClient httpClient; private final Gson gson; + private final OkHttpClient s3Client; public TurboSign(HttpClient httpClient) { this.httpClient = httpClient; this.gson = new Gson(); + this.s3Client = new OkHttpClient(); } /** * Prepare document for review without sending emails. * Use this to preview field placement before sending. */ - public PrepareForReviewResponse prepareForReview(PrepareForReviewRequest request) throws IOException { + public CreateSignatureReviewLinkResponse createSignatureReviewLink(CreateSignatureReviewLinkRequest request) throws IOException { Map formData = buildFormData( request.getRecipients(), request.getFields(), @@ -37,20 +41,15 @@ public PrepareForReviewResponse prepareForReview(PrepareForReviewRequest request request.getCcEmails() ); - DataWrapper response; - if (request.hasFile()) { String fileName = request.getFileName() != null ? request.getFileName() : "document.pdf"; - response = httpClient.uploadFile( + return httpClient.uploadFile( "/turbosign/single/prepare-for-review", request.getFile(), fileName, formData, - (Class>) (Class) DataWrapper.class + CreateSignatureReviewLinkResponse.class ); - // Re-parse with correct type - String json = gson.toJson(response); - response = gson.fromJson(json, PrepareForReviewDataWrapper.class); } else { if (request.getFileLink() != null) { formData.put("fileLink", request.getFileLink()); @@ -62,21 +61,19 @@ public PrepareForReviewResponse prepareForReview(PrepareForReviewRequest request formData.put("templateId", request.getTemplateId()); } - response = httpClient.post( + return httpClient.post( "/turbosign/single/prepare-for-review", formData, - PrepareForReviewDataWrapper.class + CreateSignatureReviewLinkResponse.class ); } - - return response.getData(); } /** * Prepare document for signing and send emails in a single call. - * This is the n8n-equivalent "Prepare for Signing" operation. + * This is the equivalent "Prepare for Signing" operation. */ - public PrepareForSigningResponse prepareForSigningSingle(PrepareForSigningRequest request) throws IOException { + public SendSignatureResponse sendSignature(SendSignatureRequest request) throws IOException { Map formData = buildFormData( request.getRecipients(), request.getFields(), @@ -87,20 +84,15 @@ public PrepareForSigningResponse prepareForSigningSingle(PrepareForSigningReques request.getCcEmails() ); - DataWrapper response; - if (request.hasFile()) { String fileName = request.getFileName() != null ? request.getFileName() : "document.pdf"; - response = httpClient.uploadFile( + return httpClient.uploadFile( "/turbosign/single/prepare-for-signing", request.getFile(), fileName, formData, - (Class>) (Class) DataWrapper.class + SendSignatureResponse.class ); - // Re-parse with correct type - String json = gson.toJson(response); - response = gson.fromJson(json, PrepareForSigningDataWrapper.class); } else { if (request.getFileLink() != null) { formData.put("fileLink", request.getFileLink()); @@ -112,32 +104,51 @@ public PrepareForSigningResponse prepareForSigningSingle(PrepareForSigningReques formData.put("templateId", request.getTemplateId()); } - response = httpClient.post( + return httpClient.post( "/turbosign/single/prepare-for-signing", formData, - PrepareForSigningDataWrapper.class + SendSignatureResponse.class ); } - - return response.getData(); } /** * Get the status of a document */ public DocumentStatusResponse getStatus(String documentId) throws IOException { - DocumentStatusDataWrapper response = httpClient.get( + return httpClient.get( "/turbosign/documents/" + documentId + "/status", - DocumentStatusDataWrapper.class + DocumentStatusResponse.class ); - return response.getData(); } /** - * Download the signed document + * Download the signed document. + * The backend returns a presigned S3 URL, which this method fetches. */ public byte[] download(String documentId) throws IOException { - return httpClient.getRaw("/turbosign/documents/" + documentId + "/download"); + // Get presigned URL from API + DownloadResponse downloadResponse = httpClient.get( + "/turbosign/documents/" + documentId + "/download", + DownloadResponse.class + ); + + if (downloadResponse.getDownloadUrl() == null || downloadResponse.getDownloadUrl().isEmpty()) { + throw new TurboDocxException("No download URL in response"); + } + + // Fetch actual file from S3 + Request request = new Request.Builder() + .url(downloadResponse.getDownloadUrl()) + .get() + .build(); + + try (Response response = s3Client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new TurboDocxException.NetworkException("Failed to download file: " + response.message()); + } + return response.body() != null ? response.body().bytes() : new byte[0]; + } } /** @@ -147,12 +158,11 @@ public VoidDocumentResponse voidDocument(String documentId, String reason) throw Map body = new HashMap<>(); body.put("reason", reason); - VoidDocumentDataWrapper response = httpClient.post( + return httpClient.post( "/turbosign/documents/" + documentId + "/void", body, - VoidDocumentDataWrapper.class + VoidDocumentResponse.class ); - return response.getData(); } /** @@ -162,12 +172,21 @@ public ResendEmailResponse resendEmail(String documentId, List recipient Map> body = new HashMap<>(); body.put("recipientIds", recipientIds); - ResendEmailDataWrapper response = httpClient.post( + return httpClient.post( "/turbosign/documents/" + documentId + "/resend-email", body, - ResendEmailDataWrapper.class + ResendEmailResponse.class + ); + } + + /** + * Get the audit trail for a document + */ + public AuditTrailResponse getAuditTrail(String documentId) throws IOException { + return httpClient.get( + "/turbosign/documents/" + documentId + "/audit-trail", + AuditTrailResponse.class ); - return response.getData(); } private Map buildFormData( @@ -196,21 +215,10 @@ private Map buildFormData( formData.put("senderEmail", senderEmail); } if (ccEmails != null && !ccEmails.isEmpty()) { - formData.put("ccEmails", String.join(",", ccEmails)); + // Use JSON for ccEmails instead of comma-join + formData.put("ccEmails", gson.toJson(ccEmails)); } return formData; } - - // Data wrapper classes for JSON deserialization - private static class DataWrapper { - private T data; - public T getData() { return data; } - } - - private static class PrepareForReviewDataWrapper extends DataWrapper {} - private static class PrepareForSigningDataWrapper extends DataWrapper {} - private static class DocumentStatusDataWrapper extends DataWrapper {} - private static class VoidDocumentDataWrapper extends DataWrapper {} - private static class ResendEmailDataWrapper extends DataWrapper {} } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/AuditTrailResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/AuditTrailResponse.java new file mode 100644 index 00000000..a4b77a13 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/AuditTrailResponse.java @@ -0,0 +1,64 @@ +package com.turbodocx.models; + +import com.google.gson.annotations.SerializedName; +import java.util.List; +import java.util.Map; + +/** + * Response from getting audit trail + */ +public class AuditTrailResponse { + @SerializedName("documentId") + private String documentId; + + @SerializedName("entries") + private List entries; + + public String getDocumentId() { + return documentId; + } + + public List getEntries() { + return entries; + } + + /** + * Single audit trail entry + */ + public static class AuditTrailEntry { + @SerializedName("event") + private String event; + + @SerializedName("actor") + private String actor; + + @SerializedName("timestamp") + private String timestamp; + + @SerializedName("ipAddress") + private String ipAddress; + + @SerializedName("details") + private Map details; + + public String getEvent() { + return event; + } + + public String getActor() { + return actor; + } + + public String getTimestamp() { + return timestamp; + } + + public String getIpAddress() { + return ipAddress; + } + + public Map getDetails() { + return details; + } + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewRequest.java b/packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkRequest.java similarity index 94% rename from packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewRequest.java rename to packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkRequest.java index 5d96101c..4f8c881c 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewRequest.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkRequest.java @@ -5,7 +5,7 @@ /** * Request for preparing a document for review */ -public class PrepareForReviewRequest { +public class CreateSignatureReviewLinkRequest { private final byte[] file; private final String fileName; private final String fileLink; @@ -19,7 +19,7 @@ public class PrepareForReviewRequest { private final String senderEmail; private final List ccEmails; - private PrepareForReviewRequest(Builder builder) { + private CreateSignatureReviewLinkRequest(Builder builder) { this.file = builder.file; this.fileName = builder.fileName; this.fileLink = builder.fileLink; @@ -160,8 +160,8 @@ public Builder ccEmails(List ccEmails) { return this; } - public PrepareForReviewRequest build() { - return new PrepareForReviewRequest(this); + public CreateSignatureReviewLinkRequest build() { + return new CreateSignatureReviewLinkRequest(this); } } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkResponse.java similarity index 70% rename from packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewResponse.java rename to packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkResponse.java index d02a787d..2e90379b 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForReviewResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/CreateSignatureReviewLinkResponse.java @@ -6,7 +6,10 @@ /** * Response from preparing a document for review */ -public class PrepareForReviewResponse { +public class CreateSignatureReviewLinkResponse { + @SerializedName("success") + private boolean success; + @SerializedName("documentId") private String documentId; @@ -16,9 +19,16 @@ public class PrepareForReviewResponse { @SerializedName("previewUrl") private String previewUrl; + @SerializedName("message") + private String message; + @SerializedName("recipients") private List recipients; + public boolean isSuccess() { + return success; + } + public String getDocumentId() { return documentId; } @@ -31,6 +41,10 @@ public String getPreviewUrl() { return previewUrl; } + public String getMessage() { + return message; + } + public List getRecipients() { return recipients; } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/DownloadResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/DownloadResponse.java new file mode 100644 index 00000000..a7f97334 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/DownloadResponse.java @@ -0,0 +1,22 @@ +package com.turbodocx.models; + +import com.google.gson.annotations.SerializedName; + +/** + * Response containing presigned URL for download + */ +public class DownloadResponse { + @SerializedName("downloadUrl") + private String downloadUrl; + + @SerializedName("fileName") + private String fileName; + + public String getDownloadUrl() { + return downloadUrl; + } + + public String getFileName() { + return fileName; + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/Field.java b/packages/java-sdk/src/main/java/com/turbodocx/models/Field.java index 811e09ad..d1f38725 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/Field.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/Field.java @@ -3,65 +3,162 @@ import com.google.gson.annotations.SerializedName; /** - * Represents a signature field + * Represents a signature field. + * Field types: signature, initial, date, text, full_name, title, company, + * first_name, last_name, email, checkbox */ public class Field { @SerializedName("type") private final String type; @SerializedName("page") - private final int page; + private final Integer page; @SerializedName("x") - private final int x; + private final Integer x; @SerializedName("y") - private final int y; + private final Integer y; @SerializedName("width") - private final int width; + private final Integer width; @SerializedName("height") - private final int height; + private final Integer height; - @SerializedName("recipientOrder") - private final int recipientOrder; + @SerializedName("recipientEmail") + private final String recipientEmail; - public Field(String type, int page, int x, int y, int width, int height, int recipientOrder) { + @SerializedName("defaultValue") + private final String defaultValue; + + @SerializedName("isMultiline") + private final Boolean isMultiline; + + @SerializedName("isReadonly") + private final Boolean isReadonly; + + @SerializedName("required") + private final Boolean required; + + @SerializedName("backgroundColor") + private final String backgroundColor; + + @SerializedName("template") + private final TemplateAnchor template; + + // Simple constructor for coordinate-based fields + public Field(String type, int page, int x, int y, int width, int height, String recipientEmail) { + this(type, page, x, y, width, height, recipientEmail, null, null, null, null, null, null); + } + + // Full constructor + public Field(String type, Integer page, Integer x, Integer y, Integer width, Integer height, + String recipientEmail, String defaultValue, Boolean isMultiline, Boolean isReadonly, + Boolean required, String backgroundColor, TemplateAnchor template) { this.type = type; this.page = page; this.x = x; this.y = y; this.width = width; this.height = height; - this.recipientOrder = recipientOrder; + this.recipientEmail = recipientEmail; + this.defaultValue = defaultValue; + this.isMultiline = isMultiline; + this.isReadonly = isReadonly; + this.required = required; + this.backgroundColor = backgroundColor; + this.template = template; } - public String getType() { - return type; + public String getType() { return type; } + public Integer getPage() { return page; } + public Integer getX() { return x; } + public Integer getY() { return y; } + public Integer getWidth() { return width; } + public Integer getHeight() { return height; } + public String getRecipientEmail() { return recipientEmail; } + public String getDefaultValue() { return defaultValue; } + public Boolean getIsMultiline() { return isMultiline; } + public Boolean getIsReadonly() { return isReadonly; } + public Boolean getRequired() { return required; } + public String getBackgroundColor() { return backgroundColor; } + public TemplateAnchor getTemplate() { return template; } + + /** + * Template anchor configuration for dynamic field positioning + */ + public static class TemplateAnchor { + @SerializedName("anchor") + private final String anchor; + + @SerializedName("searchText") + private final String searchText; + + @SerializedName("placement") + private final String placement; + + @SerializedName("size") + private final Size size; + + @SerializedName("offset") + private final Offset offset; + + @SerializedName("caseSensitive") + private final Boolean caseSensitive; + + @SerializedName("useRegex") + private final Boolean useRegex; + + public TemplateAnchor(String anchor, String searchText, String placement, + Size size, Offset offset, Boolean caseSensitive, Boolean useRegex) { + this.anchor = anchor; + this.searchText = searchText; + this.placement = placement; + this.size = size; + this.offset = offset; + this.caseSensitive = caseSensitive; + this.useRegex = useRegex; + } + + public String getAnchor() { return anchor; } + public String getSearchText() { return searchText; } + public String getPlacement() { return placement; } + public Size getSize() { return size; } + public Offset getOffset() { return offset; } + public Boolean getCaseSensitive() { return caseSensitive; } + public Boolean getUseRegex() { return useRegex; } } - public int getPage() { - return page; - } + public static class Size { + @SerializedName("width") + private final int width; - public int getX() { - return x; - } + @SerializedName("height") + private final int height; - public int getY() { - return y; - } + public Size(int width, int height) { + this.width = width; + this.height = height; + } - public int getWidth() { - return width; + public int getWidth() { return width; } + public int getHeight() { return height; } } - public int getHeight() { - return height; - } + public static class Offset { + @SerializedName("x") + private final int x; + + @SerializedName("y") + private final int y; + + public Offset(int x, int y) { + this.x = x; + this.y = y; + } - public int getRecipientOrder() { - return recipientOrder; + public int getX() { return x; } + public int getY() { return y; } } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/Recipient.java b/packages/java-sdk/src/main/java/com/turbodocx/models/Recipient.java index 5c3b2cb2..e03cf845 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/Recipient.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/Recipient.java @@ -12,13 +12,13 @@ public class Recipient { @SerializedName("email") private final String email; - @SerializedName("order") - private final int order; + @SerializedName("signingOrder") + private final int signingOrder; - public Recipient(String name, String email, int order) { + public Recipient(String name, String email, int signingOrder) { this.name = name; this.email = email; - this.order = order; + this.signingOrder = signingOrder; } public String getName() { @@ -29,7 +29,7 @@ public String getEmail() { return email; } - public int getOrder() { - return order; + public int getSigningOrder() { + return signingOrder; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/RecipientResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/RecipientResponse.java index df0ee30c..08ce6aa6 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/RecipientResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/RecipientResponse.java @@ -21,6 +21,9 @@ public class RecipientResponse { @SerializedName("signUrl") private String signUrl; + @SerializedName("signedAt") + private String signedAt; + public String getId() { return id; } @@ -40,4 +43,8 @@ public String getStatus() { public String getSignUrl() { return signUrl; } + + public String getSignedAt() { + return signedAt; + } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/ResendEmailResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/ResendEmailResponse.java index 713bb30d..000b04d2 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/ResendEmailResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/ResendEmailResponse.java @@ -6,6 +6,13 @@ * Response from resending email */ public class ResendEmailResponse { + @SerializedName("success") + private Boolean success; + + @SerializedName("recipientCount") + private Integer recipientCount; + + // Legacy fields (for backwards compatibility if backend changes) @SerializedName("documentId") private String documentId; @@ -15,6 +22,14 @@ public class ResendEmailResponse { @SerializedName("resentAt") private String resentAt; + public Boolean getSuccess() { + return success; + } + + public Integer getRecipientCount() { + return recipientCount; + } + public String getDocumentId() { return documentId; } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningRequest.java b/packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureRequest.java similarity index 95% rename from packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningRequest.java rename to packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureRequest.java index 40b437ff..10a85d41 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningRequest.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureRequest.java @@ -5,7 +5,7 @@ /** * Request for preparing a document for signing */ -public class PrepareForSigningRequest { +public class SendSignatureRequest { private final byte[] file; private final String fileName; private final String fileLink; @@ -19,7 +19,7 @@ public class PrepareForSigningRequest { private final String senderEmail; private final List ccEmails; - private PrepareForSigningRequest(Builder builder) { + private SendSignatureRequest(Builder builder) { this.file = builder.file; this.fileName = builder.fileName; this.fileLink = builder.fileLink; @@ -160,8 +160,8 @@ public Builder ccEmails(List ccEmails) { return this; } - public PrepareForSigningRequest build() { - return new PrepareForSigningRequest(this); + public SendSignatureRequest build() { + return new SendSignatureRequest(this); } } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureResponse.java similarity index 67% rename from packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningResponse.java rename to packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureResponse.java index b689cf56..78c9955f 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/PrepareForSigningResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/SendSignatureResponse.java @@ -6,16 +6,26 @@ /** * Response from preparing a document for signing */ -public class PrepareForSigningResponse { +public class SendSignatureResponse { + @SerializedName("success") + private boolean success; + @SerializedName("documentId") private String documentId; @SerializedName("status") private String status; + @SerializedName("message") + private String message; + @SerializedName("recipients") private List recipients; + public boolean isSuccess() { + return success; + } + public String getDocumentId() { return documentId; } @@ -24,6 +34,10 @@ public String getStatus() { return status; } + public String getMessage() { + return message; + } + public List getRecipients() { return recipients; } diff --git a/packages/java-sdk/src/test/java/com/turbodocx/TurboSignTest.java b/packages/java-sdk/src/test/java/com/turbodocx/TurboSignTest.java index 93f83114..e50f67e2 100644 --- a/packages/java-sdk/src/test/java/com/turbodocx/TurboSignTest.java +++ b/packages/java-sdk/src/test/java/com/turbodocx/TurboSignTest.java @@ -19,8 +19,8 @@ * TurboSign Module Tests * * Tests for 100% parity with n8n-nodes-turbodocx operations: - * - prepareForReview - * - prepareForSigningSingle + * - createSignatureReviewLink + * - sendSignature * - getStatus * - download * - voidDocument @@ -39,6 +39,7 @@ void setUp() throws IOException { client = new TurboDocxClient.Builder() .apiKey("test-api-key") + .orgId("test-org-id") .baseUrl(server.url("/").toString()) .build(); } @@ -49,14 +50,15 @@ void tearDown() throws IOException { } // ============================================ - // Configure Tests (2) + // Configure Tests (4) // ============================================ @Test - @DisplayName("should configure the client with API key") - void configureWithApiKey() { + @DisplayName("should configure the client with API key and orgId") + void configureWithApiKeyAndOrgId() { TurboDocxClient testClient = new TurboDocxClient.Builder() .apiKey("test-api-key") + .orgId("test-org-id") .build(); assertNotNull(testClient); assertNotNull(testClient.turboSign()); @@ -67,42 +69,56 @@ void configureWithApiKey() { void configureWithCustomBaseUrl() { TurboDocxClient testClient = new TurboDocxClient.Builder() .apiKey("test-api-key") + .orgId("test-org-id") .baseUrl("https://custom-api.example.com") .build(); assertNotNull(testClient); } + @Test + @DisplayName("should throw error when orgId is not configured") + void errorWhenNoOrgId() { + assertThrows(TurboDocxException.AuthenticationException.class, () -> { + new TurboDocxClient.Builder() + .apiKey("test-api-key") + .build(); + }); + } + // ============================================ // PrepareForReview Tests (5) // ============================================ @Test @DisplayName("should prepare document for review with file upload") - void prepareForReviewWithFileUpload() throws Exception { + void createSignatureReviewLinkWithFileUpload() throws Exception { Map responseData = new HashMap<>(); + responseData.put("success", true); responseData.put("documentId", "doc-123"); responseData.put("status", "review_ready"); responseData.put("previewUrl", "https://preview.example.com/doc-123"); + responseData.put("message", "Document prepared for review"); server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", responseData)))); + .setBody(gson.toJson(responseData))); - PrepareForReviewRequest request = new PrepareForReviewRequest.Builder() + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() .file(new byte[]{0x25, 0x50, 0x44, 0x46}) // %PDF .fileName("contract.pdf") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForReviewResponse result = client.turboSign().prepareForReview(request); + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); assertEquals("doc-123", result.getDocumentId()); assertEquals("review_ready", result.getStatus()); assertNotNull(result.getPreviewUrl()); + assertTrue(result.isSuccess()); RecordedRequest recorded = server.takeRequest(); assertTrue(recorded.getHeader("Content-Type").contains("multipart/form-data")); @@ -110,8 +126,9 @@ void prepareForReviewWithFileUpload() throws Exception { @Test @DisplayName("should prepare document for review with file URL") - void prepareForReviewWithFileUrl() throws Exception { + void createSignatureReviewLinkWithFileUrl() throws Exception { Map responseData = new HashMap<>(); + responseData.put("success", true); responseData.put("documentId", "doc-456"); responseData.put("status", "review_ready"); responseData.put("previewUrl", "https://preview.example.com/doc-456"); @@ -119,17 +136,17 @@ void prepareForReviewWithFileUrl() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", responseData)))); + .setBody(gson.toJson(responseData))); - PrepareForReviewRequest request = new PrepareForReviewRequest.Builder() + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() .fileLink("https://storage.example.com/contract.pdf") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForReviewResponse result = client.turboSign().prepareForReview(request); + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); assertEquals("doc-456", result.getDocumentId()); @@ -140,69 +157,72 @@ void prepareForReviewWithFileUrl() throws Exception { @Test @DisplayName("should prepare document for review with deliverable ID") - void prepareForReviewWithDeliverableId() throws Exception { + void createSignatureReviewLinkWithDeliverableId() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( + "success", true, "documentId", "doc-789", "status", "review_ready" - ))))); + )))); - PrepareForReviewRequest request = new PrepareForReviewRequest.Builder() + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() .deliverableId("deliverable-abc") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForReviewResponse result = client.turboSign().prepareForReview(request); + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); assertEquals("doc-789", result.getDocumentId()); } @Test @DisplayName("should prepare document for review with template ID") - void prepareForReviewWithTemplateId() throws Exception { + void createSignatureReviewLinkWithTemplateId() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( + "success", true, "documentId", "doc-template", "status", "review_ready" - ))))); + )))); - PrepareForReviewRequest request = new PrepareForReviewRequest.Builder() + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() .templateId("template-xyz") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForReviewResponse result = client.turboSign().prepareForReview(request); + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); assertEquals("doc-template", result.getDocumentId()); } @Test @DisplayName("should include optional fields in request") - void prepareForReviewWithOptionalFields() throws Exception { + void createSignatureReviewLinkWithOptionalFields() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( + "success", true, "documentId", "doc-optional", "status", "review_ready" - ))))); + )))); - PrepareForReviewRequest request = new PrepareForReviewRequest.Builder() + CreateSignatureReviewLinkRequest request = new CreateSignatureReviewLinkRequest.Builder() .fileLink("https://example.com/doc.pdf") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .documentName("Test Contract") .documentDescription("A test contract") .senderName("Sales Team") @@ -210,7 +230,7 @@ void prepareForReviewWithOptionalFields() throws Exception { .ccEmails(Arrays.asList("admin@company.com", "legal@company.com")) .build(); - PrepareForReviewResponse result = client.turboSign().prepareForReview(request); + CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink(request); assertEquals("doc-optional", result.getDocumentId()); } @@ -221,7 +241,7 @@ void prepareForReviewWithOptionalFields() throws Exception { @Test @DisplayName("should prepare document for signing and send emails") - void prepareForSigningSingleWithUrl() throws Exception { + void sendSignatureWithUrl() throws Exception { Map recipient = new HashMap<>(); recipient.put("id", "rec-1"); recipient.put("name", "John Doe"); @@ -230,24 +250,26 @@ void prepareForSigningSingleWithUrl() throws Exception { recipient.put("signUrl", "https://sign.example.com/rec-1"); Map responseData = new HashMap<>(); + responseData.put("success", true); responseData.put("documentId", "doc-123"); responseData.put("status", "sent"); + responseData.put("message", "Document sent for signing"); responseData.put("recipients", Collections.singletonList(recipient)); server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", responseData)))); + .setBody(gson.toJson(responseData))); - PrepareForSigningRequest request = new PrepareForSigningRequest.Builder() + SendSignatureRequest request = new SendSignatureRequest.Builder() .fileLink("https://storage.example.com/contract.pdf") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForSigningResponse result = client.turboSign().prepareForSigningSingle(request); + SendSignatureResponse result = client.turboSign().sendSignature(request); assertEquals("doc-123", result.getDocumentId()); assertEquals("sent", result.getStatus()); @@ -261,26 +283,27 @@ void prepareForSigningSingleWithUrl() throws Exception { @Test @DisplayName("should handle file upload for signing") - void prepareForSigningSingleWithFileUpload() throws Exception { + void sendSignatureWithFileUpload() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( + "success", true, "documentId", "doc-upload", "status", "sent", "recipients", Collections.emptyList() - ))))); + )))); - PrepareForSigningRequest request = new PrepareForSigningRequest.Builder() + SendSignatureRequest request = new SendSignatureRequest.Builder() .file(new byte[]{0x25, 0x50, 0x44, 0x46}) .fileName("contract.pdf") .recipients(Collections.singletonList( new Recipient("John Doe", "john@example.com", 1))) .fields(Collections.singletonList( - new Field("signature", 1, 100, 500, 200, 50, 1))) + new Field("signature", 1, 100, 500, 200, 50, "john@example.com"))) .build(); - PrepareForSigningResponse result = client.turboSign().prepareForSigningSingle(request); + SendSignatureResponse result = client.turboSign().sendSignature(request); assertEquals("doc-upload", result.getDocumentId()); @@ -312,7 +335,7 @@ void getStatus() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", responseData)))); + .setBody(gson.toJson(responseData))); DocumentStatusResponse result = client.turboSign().getStatus("doc-123"); @@ -325,29 +348,6 @@ void getStatus() throws Exception { assertEquals("/turbosign/documents/doc-123/status", recorded.getPath()); } - // ============================================ - // Download Test (1) - // ============================================ - - @Test - @DisplayName("should download signed document") - void download() throws Exception { - byte[] pdfContent = new byte[]{0x25, 0x50, 0x44, 0x46}; // %PDF - - server.enqueue(new MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/pdf") - .setBody(new okio.Buffer().write(pdfContent))); - - byte[] result = client.turboSign().download("doc-123"); - - assertArrayEquals(pdfContent, result); - - RecordedRequest recorded = server.takeRequest(); - assertEquals("GET", recorded.getMethod()); - assertEquals("/turbosign/documents/doc-123/download", recorded.getPath()); - } - // ============================================ // Void Test (1) // ============================================ @@ -358,11 +358,11 @@ void voidDocument() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( "documentId", "doc-123", "status", "voided", "voidedAt", "2024-01-01T12:00:00Z" - ))))); + )))); VoidDocumentResponse result = client.turboSign().voidDocument("doc-123", "Document needs revision"); @@ -384,11 +384,11 @@ void resendEmail() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(gson.toJson(Map.of("data", Map.of( + .setBody(gson.toJson(Map.of( "documentId", "doc-123", "message", "Emails resent successfully", "resentAt", "2024-01-01T12:00:00Z" - ))))); + )))); ResendEmailResponse result = client.turboSign().resendEmail("doc-123", Arrays.asList("rec-1", "rec-2")); @@ -400,20 +400,55 @@ void resendEmail() throws Exception { } // ============================================ - // Error Handling Tests (3) + // GetAuditTrail Test (1) + // ============================================ + + @Test + @DisplayName("should get audit trail for document") + void getAuditTrail() throws Exception { + Map entry1 = new HashMap<>(); + entry1.put("event", "document_created"); + entry1.put("actor", "sender@example.com"); + entry1.put("timestamp", "2024-01-01T00:00:00Z"); + + Map entry2 = new HashMap<>(); + entry2.put("event", "document_signed"); + entry2.put("actor", "john@example.com"); + entry2.put("timestamp", "2024-01-01T12:00:00Z"); + + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(gson.toJson(Map.of( + "documentId", "doc-123", + "entries", Arrays.asList(entry1, entry2) + )))); + + AuditTrailResponse result = client.turboSign().getAuditTrail("doc-123"); + + assertEquals("doc-123", result.getDocumentId()); + assertEquals(2, result.getEntries().size()); + + RecordedRequest recorded = server.takeRequest(); + assertEquals("GET", recorded.getMethod()); + assertEquals("/turbosign/documents/doc-123/audit-trail", recorded.getPath()); + } + + // ============================================ + // Error Handling Tests (5) // ============================================ @Test @DisplayName("should throw error when API key is not configured") void errorWhenNoApiKey() { assertThrows(IllegalArgumentException.class, () -> { - new TurboDocxClient.Builder().build(); + new TurboDocxClient.Builder().orgId("test-org").build(); }); } @Test - @DisplayName("should handle API errors gracefully") - void handleApiError() { + @DisplayName("should throw NotFoundException for 404 errors") + void handleNotFoundError() { server.enqueue(new MockResponse() .setResponseCode(404) .setHeader("Content-Type", "application/json") @@ -422,7 +457,7 @@ void handleApiError() { "code", "DOCUMENT_NOT_FOUND" )))); - TurboDocxException exception = assertThrows(TurboDocxException.class, () -> { + TurboDocxException.NotFoundException exception = assertThrows(TurboDocxException.NotFoundException.class, () -> { client.turboSign().getStatus("invalid-doc"); }); @@ -432,7 +467,7 @@ void handleApiError() { } @Test - @DisplayName("should handle validation errors") + @DisplayName("should throw ValidationException for 400 errors") void handleValidationError() { server.enqueue(new MockResponse() .setResponseCode(400) @@ -442,18 +477,54 @@ void handleValidationError() { "code", "VALIDATION_ERROR" )))); - PrepareForSigningRequest request = new PrepareForSigningRequest.Builder() + SendSignatureRequest request = new SendSignatureRequest.Builder() .fileLink("https://example.com/doc.pdf") .recipients(Collections.singletonList( new Recipient("Test", "invalid-email", 1))) .fields(Collections.emptyList()) .build(); - TurboDocxException exception = assertThrows(TurboDocxException.class, () -> { - client.turboSign().prepareForSigningSingle(request); + TurboDocxException.ValidationException exception = assertThrows(TurboDocxException.ValidationException.class, () -> { + client.turboSign().sendSignature(request); }); assertEquals(400, exception.getStatusCode()); assertTrue(exception.getMessage().contains("Validation")); } + + @Test + @DisplayName("should throw AuthenticationException for 401 errors") + void handleAuthenticationError() { + server.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody(gson.toJson(Map.of( + "message", "Invalid API key", + "code", "UNAUTHORIZED" + )))); + + TurboDocxException.AuthenticationException exception = assertThrows(TurboDocxException.AuthenticationException.class, () -> { + client.turboSign().getStatus("doc-123"); + }); + + assertEquals(401, exception.getStatusCode()); + } + + @Test + @DisplayName("should throw RateLimitException for 429 errors") + void handleRateLimitError() { + server.enqueue(new MockResponse() + .setResponseCode(429) + .setHeader("Content-Type", "application/json") + .setBody(gson.toJson(Map.of( + "message", "Rate limit exceeded", + "code", "RATE_LIMIT_EXCEEDED" + )))); + + TurboDocxException.RateLimitException exception = assertThrows(TurboDocxException.RateLimitException.class, () -> { + client.turboSign().getStatus("doc-123"); + }); + + assertEquals(429, exception.getStatusCode()); + } } diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index 80da0ae3..bca7d3ab 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -63,7 +63,7 @@ TurboSign.configure({ }); // 2. Send a document for signature -const result = await TurboSign.prepareForSigningSingle({ +const result = await TurboSign.sendSignature({ fileLink: 'https://example.com/contract.pdf', recipients: [ { name: 'John Doe', email: 'john@example.com', order: 1 } @@ -117,12 +117,12 @@ TurboSign.configure({ ### TurboSign -#### `prepareForReview(options)` +#### `createSignatureReviewLink(options)` Upload a document for review without sending signature emails. Returns a preview URL. ```typescript -const result = await TurboSign.prepareForReview({ +const result = await TurboSign.createSignatureReviewLink({ fileLink: 'https://example.com/contract.pdf', recipients: [ { name: 'John Doe', email: 'john@example.com', order: 1 } @@ -141,12 +141,12 @@ console.log('Preview URL:', result.previewUrl); console.log('Document ID:', result.documentId); ``` -#### `prepareForSigningSingle(options)` +#### `sendSignature(options)` Upload a document and immediately send signature request emails. ```typescript -const result = await TurboSign.prepareForSigningSingle({ +const result = await TurboSign.sendSignature({ fileLink: 'https://example.com/contract.pdf', recipients: [ { name: 'Alice', email: 'alice@example.com', order: 1 }, @@ -247,7 +247,7 @@ await TurboSign.resend('doc-uuid-here', ['recipient-uuid-1', 'recipient-uuid-2'] ### Sequential Signing (Multiple Recipients) ```typescript -const result = await TurboSign.prepareForSigningSingle({ +const result = await TurboSign.sendSignature({ fileLink: 'https://example.com/contract.pdf', recipients: [ { name: 'Employee', email: 'employee@company.com', order: 1 }, @@ -303,7 +303,7 @@ TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY }); app.post('/api/send-contract', async (req, res) => { try { - const result = await TurboSign.prepareForSigningSingle({ + const result = await TurboSign.sendSignature({ fileLink: req.body.pdfUrl, recipients: req.body.recipients, fields: req.body.fields @@ -318,6 +318,50 @@ app.post('/api/send-contract', async (req, res) => { --- +## Local Testing + +The SDK includes a comprehensive manual test script to verify all functionality locally. + +### Running Manual Tests + +```bash +# Install dependencies +npm install + +# Run the manual test script +npx tsx manual-test.ts +``` + +### What It Tests + +The `manual-test.ts` file tests all SDK methods: +- ✅ `createSignatureReviewLink()` - Document upload for review +- ✅ `sendSignature()` - Send for signature +- ✅ `getStatus()` - Check document status +- ✅ `download()` - Download signed document +- ✅ `void()` - Cancel signature request +- ✅ `resend()` - Resend signature emails + +### Configuration + +Before running, update the hardcoded values in `manual-test.ts`: +- `API_KEY` - Your TurboDocx API key +- `BASE_URL` - API endpoint (default: `http://localhost:3000`) +- `ORG_ID` - Your organization UUID +- `TEST_FILE_PATH` - Path to a test PDF/DOCX file +- `TEST_EMAIL` - Email address for testing + +### Expected Output + +The script will: +1. Upload a test document +2. Send it for signature +3. Check the status +4. Test void and resend operations +5. Print results for each operation + +--- + ## Error Handling ```typescript @@ -355,8 +399,8 @@ Full TypeScript support with exported types: ```typescript import { TurboSign, - PrepareForSigningOptions, - PrepareForReviewOptions, + SendSignatureRequest, + CreateSignatureReviewLinkRequest, Recipient, Field, DocumentStatus, @@ -364,13 +408,13 @@ import { } from '@turbodocx/sdk'; // Type-safe options -const options: PrepareForSigningOptions = { +const options: SendSignatureRequest = { fileLink: 'https://example.com/contract.pdf', - recipients: [{ name: 'John', email: 'john@example.com', order: 1 }], - fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }] + recipients: [{ name: 'John', email: 'john@example.com', signingOrder: 1 }], + fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: 'john@example.com' }] }; -const result = await TurboSign.prepareForSigningSingle(options); +const result = await TurboSign.sendSignature(options); ``` --- diff --git a/packages/js-sdk/manual-test.ts b/packages/js-sdk/manual-test.ts new file mode 100644 index 00000000..b92445c6 --- /dev/null +++ b/packages/js-sdk/manual-test.ts @@ -0,0 +1,201 @@ +/// +import { TurboSign } from "./src"; +import * as fs from "fs"; + +// ============================================= +// CONFIGURE THESE VALUES BEFORE RUNNING +// ============================================= +TurboSign.configure({ + apiKey: "TDX-your-api-key-here", // Replace with your actual TurboDocx API key + baseUrl: "http://localhost:3000", // Replace with your API URL + orgId: "your-organization-uuid-here", // Replace with your organization UUID +}); + +const TEST_PDF_PATH = "/path/to/your/test-document.pdf"; // Replace with path to your test PDF/DOCX +const TEST_EMAIL = "test-recipient@example.com"; // Replace with a real email to receive notifications + +// ============================================= +// TEST FUNCTIONS +// ============================================= + +async function testCreateSignatureReviewLink() { + console.log("\n--- Test 1: createSignatureReviewLink ---"); + const pdfBuffer = fs.readFileSync(TEST_PDF_PATH); + + const result = await TurboSign.createSignatureReviewLink({ + // file: pdfBuffer, + templateId: "your-template-uuid-here", // Replace with your template ID + recipients: [{ name: "Test User", email: TEST_EMAIL, signingOrder: 1 }], + fields: [ + { + recipientEmail: TEST_EMAIL, + type: "text", + template: { + anchor: "{placeholder}", + placement: "replace", + size: { width: 200, height: 80 }, + offset: { x: 0, y: 0 }, + caseSensitive: true, + useRegex: false, + }, + defaultValue: "Sample Text", + required: true, + isMultiline: true, + }, + { + recipientEmail: TEST_EMAIL, + type: "last_name", + page: 1, + x: 100, + y: 650, + width: 200, + height: 50, + defaultValue: "Doe", + }, + ], + documentName: "Review Test Document", + }); + + console.log("Result:", JSON.stringify(result, null, 2)); + return result.documentId; +} + +async function testSendSignature() { + console.log("\n--- Test 2: sendSignature ---"); + const pdfBuffer = fs.readFileSync(TEST_PDF_PATH); + + const result = await TurboSign.sendSignature({ + file: pdfBuffer, + // templateId: "341af877-02d4-4549-823b-87089a3f7b02", + recipients: [{ name: "Signer One", email: TEST_EMAIL, signingOrder: 1 }], + fields: [ + { + recipientEmail: TEST_EMAIL, + type: "signature", + page: 1, + x: 100, + y: 550, + width: 200, + height: 50, + }, + { + recipientEmail: TEST_EMAIL, + type: "checkbox", + page: 1, + x: 320, + y: 550, + width: 50, + height: 50, + defaultValue: "true", + }, + ], + documentName: "Signing Test Document", + documentDescription: + "Sample contract for testing single-step signature endpoint", + senderName: "Test Sender", + senderEmail: "sender@example.com", + ccEmails: ["cc@example.com"] + }); + + console.log("Result:", result); + return result.documentId; +} + +async function testGetStatus(documentId: string) { + console.log("\n--- Test 3: getStatus ---"); + const result = await TurboSign.getStatus(documentId); + console.log("Result:", JSON.stringify(result, null, 2)); + return result; +} + +async function testDownload(documentId: string) { + console.log("\n--- Test 4: download ---"); + const result = await TurboSign.download(documentId); + console.log("Result: Blob received, size:", result.size, "bytes"); + + // Save to file + const buffer = Buffer.from(await result.arrayBuffer()); + const outputPath = "./downloaded-document.pdf"; + fs.writeFileSync(outputPath, buffer); + console.log(`File saved to: ${outputPath}`); + + return buffer; +} + +async function testResend(documentId: string, recipientIds: string[]) { + console.log("\n--- Test 5: resend ---"); + const result = await TurboSign.resend(documentId, recipientIds); + console.log("Result:", JSON.stringify(result, null, 2)); + return result; +} + +async function testVoid(documentId: string) { + console.log("\n--- Test 6: void ---"); + const result = await TurboSign.void(documentId, "Testing void functionality"); + console.log("Result:", JSON.stringify(result, null, 2)); + return result; +} + +async function testGetAuditTrail(documentId: string) { + console.log("\n--- Test 7: getAuditTrail ---"); + const result = await TurboSign.getAuditTrail(documentId); + console.log("Result:", JSON.stringify(result, null, 2)); + return result; +} + +// ============================================= +// MAIN TEST RUNNER +// ============================================= + +async function runAllTests() { + console.log("=============================================="); + console.log("TurboSign JS SDK - Manual Test Suite"); + console.log("=============================================="); + + // Check if test PDF exists + if (!fs.existsSync(TEST_PDF_PATH)) { + console.error(`\nError: Test PDF not found at ${TEST_PDF_PATH}`); + console.log("Please add a test PDF file and try again."); + process.exit(1); + } + + try { + // Uncomment and run tests as needed: + + // Test 1: Create Signature Review Link + // const reviewDocId = await testCreateSignatureReviewLink(); + + // Test 2: Send Signature (creates a new document) + // const signDocId = await testSendSignature(); + + // Test 3: Get Status (replace with actual document ID) + // await testGetStatus("document-uuid-here"); + + // Test 4: Download (replace with actual document ID) + // await testDownload("document-uuid-here"); + + // Test 5: Resend (replace with actual document ID and recipient ID) + // await testResend("document-uuid-here", ["recipient-uuid-here"]); + + // Test 6: Void (do this last as it cancels the document) + // await testVoid("document-uuid-here"); + + // Test 7: Get Audit Trail (replace with actual document ID) + // await testGetAuditTrail("document-uuid-here"); + + console.log("\n=============================================="); + console.log("All tests completed successfully!"); + console.log("=============================================="); + } catch (error: any) { + console.error("\n=============================================="); + console.error("TEST FAILED"); + console.error("=============================================="); + console.error("Error:", error.message || error); + if (error.statusCode) console.error("Status Code:", error.statusCode); + if (error.code) console.error("Error Code:", error.code); + process.exit(1); + } +} + +// Run tests +runAllTests(); diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 7a9c6aad..256d562d 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@types/jest": "^29.5.12", + "@types/node": "^24.10.1", "jest": "^29.7.0", "ts-jest": "^29.1.2", "typescript": "^5.6.3" diff --git a/packages/js-sdk/src/http.ts b/packages/js-sdk/src/http.ts index c3803182..b70974ee 100644 --- a/packages/js-sdk/src/http.ts +++ b/packages/js-sdk/src/http.ts @@ -2,38 +2,107 @@ * HTTP client for TurboDocx API */ -import { TurboDocxError, AuthenticationError, NetworkError } from './utils/errors'; +import * as fs from 'fs'; +import * as nodePath from 'path'; +import { TurboDocxError, AuthenticationError, ValidationError, NotFoundError, RateLimitError, NetworkError } from './utils/errors'; export interface HttpClientConfig { apiKey?: string; accessToken?: string; baseUrl?: string; + orgId?: string; } +/** + * Detect file type from buffer content using magic bytes + * - PDF: starts with %PDF (0x25 0x50 0x44 0x46) + * - DOCX/PPTX: starts with PK (ZIP), differentiate by internal content + */ +const detectFileType = (buffer: Buffer): { mimetype: string; extension: string } => { + // PDF: %PDF + if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) { + return { mimetype: 'application/pdf', extension: 'pdf' }; + } + + // ZIP-based formats (DOCX, PPTX): starts with PK (0x50 0x4B) + if (buffer[0] === 0x50 && buffer[1] === 0x4B) { + // Convert buffer to string to search for internal markers + const bufferStr = buffer.toString('utf8', 0, Math.min(buffer.length, 2000)); + + // PPTX contains 'ppt/' in the ZIP structure + if (bufferStr.includes('ppt/')) { + return { + mimetype: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + extension: 'pptx' + }; + } + + // DOCX contains 'word/' in the ZIP structure + if (bufferStr.includes('word/')) { + return { + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + extension: 'docx' + }; + } + + // Default to DOCX if it's a ZIP but can't determine type + return { + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + extension: 'docx' + }; + } + + // Unknown file type + return { mimetype: 'application/octet-stream', extension: 'bin' }; +}; + export class HttpClient { private apiKey?: string; private accessToken?: string; private baseUrl: string; + private orgId?: string; constructor(config: HttpClientConfig = {}) { this.apiKey = config.apiKey || process.env.TURBODOCX_API_KEY; this.accessToken = config.accessToken; this.baseUrl = config.baseUrl || process.env.TURBODOCX_BASE_URL || 'https://api.turbodocx.com'; + this.orgId = config.orgId || process.env.TURBODOCX_ORG_ID; if (!this.apiKey && !this.accessToken) { throw new AuthenticationError('API key or access token is required'); } } + /** + * Smart unwrap response data. + * If response has ONLY "data" key, extract it. + * This handles backend responses that wrap data in { "data": { ... } } + */ + private smartUnwrap(data: any): T { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const keys = Object.keys(data); + if (keys.length === 1 && keys[0] === 'data') { + return data.data as T; + } + } + return data as T; + } + private getHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', }; + // API key is sent as Bearer token (backend expects Authorization header) if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } else if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + + // Organization ID header (required by backend) + if (this.orgId) { + headers['x-rapiddocx-org-id'] = this.orgId; } return headers; @@ -62,7 +131,8 @@ export class HttpClient { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { - return await response.json() as T; + const jsonData = await response.json(); + return this.smartUnwrap(jsonData); } return response as any; @@ -75,34 +145,97 @@ export class HttpClient { } async uploadFile( - path: string, - file: File | Buffer, + apiPath: string, + file: string | File | Buffer, fieldName: string = 'file', additionalData?: Record ): Promise { - const url = `${this.baseUrl}${path}`; + const url = `${this.baseUrl}${apiPath}`; const formData = new FormData(); - // Add file to form data - if (file instanceof Buffer) { - const blob = new Blob([file]); - formData.append(fieldName, blob); + let fileBuffer: Buffer; + let fileName: string; + let mimeType: string; + + if (typeof file === 'string') { + // File path: read file and detect type from content + fileBuffer = fs.readFileSync(file); + const detected = detectFileType(fileBuffer); + fileName = nodePath.basename(file); + mimeType = detected.mimetype; + } else if (file instanceof Buffer) { + // Buffer: detect type from content + fileBuffer = file; + const detected = detectFileType(fileBuffer); + fileName = additionalData?.fileName || `document.${detected.extension}`; + mimeType = detected.mimetype; } else { - formData.append(fieldName, file); + // Browser File object: use native properties + const browserFile = file as File; + formData.append(fieldName, browserFile, browserFile.name); + + // Add additional form fields + if (additionalData) { + Object.entries(additionalData).forEach(([key, value]) => { + if (key === 'fileName') return; + formData.append(key, typeof value === 'object' ? JSON.stringify(value) : value); + }); + } + + // Make request for browser File + const headers: Record = {}; + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } else if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + if (this.orgId) { + headers['x-rapiddocx-org-id'] = this.orgId; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + const jsonData = await response.json(); + return this.smartUnwrap(jsonData); + } catch (error) { + if (error instanceof TurboDocxError) { + throw error; + } + throw new NetworkError(`File upload failed: ${error}`); + } } - // Add additional form fields + // Create blob with detected mimetype and append with filename + const blob = new Blob([fileBuffer], { type: mimeType }); + formData.append(fieldName, blob, fileName); + + // Add additional form fields (except fileName which is only used for file metadata) if (additionalData) { Object.entries(additionalData).forEach(([key, value]) => { + if (key === 'fileName') return; // Skip fileName - it's used for file blob, not as form field formData.append(key, typeof value === 'object' ? JSON.stringify(value) : value); }); } const headers: Record = {}; + // API key is sent as Bearer token (backend expects Authorization header) if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } else if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + // Organization ID header (required by backend) + if (this.orgId) { + headers['x-rapiddocx-org-id'] = this.orgId; } try { @@ -116,7 +249,8 @@ export class HttpClient { await this.handleErrorResponse(response); } - return await response.json() as T; + const jsonData = await response.json(); + return this.smartUnwrap(jsonData); } catch (error) { if (error instanceof TurboDocxError) { throw error; @@ -127,36 +261,48 @@ export class HttpClient { private async handleErrorResponse(response: Response): Promise { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; - let errorCode: string | undefined; try { const errorData = await response.json() as { message?: string; error?: string; code?: string }; errorMessage = errorData.message || errorData.error || errorMessage; - errorCode = errorData.code; } catch { // If response is not JSON, use status text } + if (response.status === 400) { + throw new ValidationError(errorMessage); + } if (response.status === 401) { throw new AuthenticationError(errorMessage); } + if (response.status === 404) { + throw new NotFoundError(errorMessage); + } + if (response.status === 429) { + throw new RateLimitError(errorMessage); + } - throw new TurboDocxError(errorMessage, response.status, errorCode); + throw new TurboDocxError(errorMessage, response.status); } - async get(path: string, options?: RequestInit): Promise { - return this.request('GET', path, undefined, options); + async get(path: string, params?: Record, options?: RequestInit): Promise { + let url = path; + if (params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + } + const queryString = searchParams.toString(); + if (queryString) { + url += '?' + queryString; + } + } + return this.request('GET', url, undefined, options); } async post(path: string, data?: any, options?: RequestInit): Promise { return this.request('POST', path, data, options); } - - async patch(path: string, data?: any, options?: RequestInit): Promise { - return this.request('PATCH', path, data, options); - } - - async delete(path: string, options?: RequestInit): Promise { - return this.request('DELETE', path, undefined, options); - } } diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index a415fadd..1a31d97e 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -4,11 +4,9 @@ // Export modules export { TurboSign } from './modules/sign'; -export { Webhooks } from './modules/webhooks'; // Export types export * from './types/sign'; -export * from './types/webhooks'; // Export errors export * from './utils/errors'; diff --git a/packages/js-sdk/src/modules/sign.ts b/packages/js-sdk/src/modules/sign.ts index e55cc0a2..261ec069 100644 --- a/packages/js-sdk/src/modules/sign.ts +++ b/packages/js-sdk/src/modules/sign.ts @@ -4,34 +4,15 @@ import { HttpClient, HttpClientConfig } from '../http'; import { - UploadDocumentResponse, - AddRecipientsRequest, - AddRecipientsResponse, - PrepareSigningRequest, - PrepareSigningResponse, VoidDocumentResponse, ResendEmailResponse, AuditTrailResponse, DocumentStatusResponse, - DocumentWithRecipients, - DocumentFileResponse, - DocumentFieldResponse, - SignatureDocumentListItem, - RecipientFieldResponse, - SendDocumentRequest, - SendDocumentResponse, - SubmitSignedDocumentResponse, - PublicDocumentStatusResponse, - PrepareForReviewRequest, - PrepareForReviewResponse, - PrepareForSigningSingleRequest, - PrepareForSigningSingleResponse, + CreateSignatureReviewLinkRequest, + CreateSignatureReviewLinkResponse, + SendSignatureRequest, + SendSignatureResponse, } from '../types/sign'; -import { - generateRecipientColors, - normalizeFields, - extractFileName -} from '../utils/field-helpers'; export class TurboSign { private static client: HttpClient; @@ -55,11 +36,11 @@ export class TurboSign { } // ============================================ - // N8N PARITY METHODS (single-call operations) + // SINGLE-STEP OPERATIONS // ============================================ /** - * Prepare document for review without sending emails + * Create signature review link without sending emails * * This method uploads a document with signature fields and recipients, * but does NOT send signature request emails. Use this to preview @@ -71,28 +52,28 @@ export class TurboSign { * @example * ```typescript * // Using file upload - * const result = await TurboSign.prepareForReview({ + * const result = await TurboSign.createSignatureReviewLink({ * file: pdfBuffer, - * recipients: [{ name: 'John Doe', email: 'john@example.com', order: 1 }], - * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }] + * recipients: [{ name: 'John Doe', email: 'john@example.com', signingOrder: 1 }], + * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: 'john@example.com' }] * }); * * // Using file URL - * const result = await TurboSign.prepareForReview({ + * const result = await TurboSign.createSignatureReviewLink({ * fileLink: 'https://storage.example.com/contract.pdf', - * recipients: [{ name: 'John Doe', email: 'john@example.com', order: 1 }], - * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }] + * recipients: [{ name: 'John Doe', email: 'john@example.com', signingOrder: 1 }], + * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: 'john@example.com' }] * }); * * // Using deliverable ID (from TurboDocx document generation) - * const result = await TurboSign.prepareForReview({ + * const result = await TurboSign.createSignatureReviewLink({ * deliverableId: 'deliverable-uuid', - * recipients: [{ name: 'John Doe', email: 'john@example.com', order: 1 }], - * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }] + * recipients: [{ name: 'John Doe', email: 'john@example.com', signingOrder: 1 }], + * fields: [{ type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: 'john@example.com' }] * }); * ``` */ - static async prepareForReview(request: PrepareForReviewRequest): Promise { + static async createSignatureReviewLink(request: CreateSignatureReviewLinkRequest): Promise { const client = this.getClient(); // Serialize recipients and fields to JSON strings (as n8n node does) @@ -112,63 +93,60 @@ export class TurboSign { if (request.senderEmail) formData.senderEmail = request.senderEmail; if (request.ccEmails) { formData.ccEmails = Array.isArray(request.ccEmails) - ? request.ccEmails.join(',') - : request.ccEmails; + ? JSON.stringify(request.ccEmails) + : JSON.stringify([request.ccEmails]); } // Handle different file input methods if (request.file) { // File upload - use multipart form - const response = await client.uploadFile<{ data: PrepareForReviewResponse }>( + const response = await client.uploadFile( '/turbosign/single/prepare-for-review', request.file, 'file', formData ); - return response.data; + return response; } else { // URL, deliverable, or template - use JSON body if (request.fileLink) formData.fileLink = request.fileLink; if (request.deliverableId) formData.deliverableId = request.deliverableId; if (request.templateId) formData.templateId = request.templateId; - const response = await client.post<{ data: PrepareForReviewResponse }>( + const response = await client.post( '/turbosign/single/prepare-for-review', formData ); - return response.data; + return response; } } /** - * Prepare document for signing and send emails in a single call + * Send signature request and immediately send emails * * This method uploads a document with signature fields and recipients, * then immediately sends signature request emails to all recipients. - * This is the n8n-equivalent "Prepare for Signing" operation. * * @param request - Document, recipients, and fields configuration - * @returns Document with sign URLs for each recipient + * @returns Document with confirmation message * * @example * ```typescript * // Using file upload - * const result = await TurboSign.prepareForSigningSingle({ + * const result = await TurboSign.sendSignature({ * file: pdfBuffer, * recipients: [ - * { name: 'John Doe', email: 'john@example.com', order: 1 }, - * { name: 'Jane Smith', email: 'jane@example.com', order: 2 } + * { name: 'John Doe', email: 'john@example.com', signingOrder: 1 }, + * { name: 'Jane Smith', email: 'jane@example.com', signingOrder: 2 } * ], * fields: [ - * { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }, - * { type: 'signature', page: 1, x: 100, y: 600, width: 200, height: 50, recipientOrder: 2 } + * { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: 'john@example.com' }, + * { type: 'signature', page: 1, x: 100, y: 600, width: 200, height: 50, recipientEmail: 'jane@example.com' } * ] * }); - * - * console.log(result.recipients[0].signUrl); // URL for first recipient to sign * ``` */ - static async prepareForSigningSingle(request: PrepareForSigningSingleRequest): Promise { + static async sendSignature(request: SendSignatureRequest): Promise { const client = this.getClient(); // Serialize recipients and fields to JSON strings (as n8n node does) @@ -188,427 +166,38 @@ export class TurboSign { if (request.senderEmail) formData.senderEmail = request.senderEmail; if (request.ccEmails) { formData.ccEmails = Array.isArray(request.ccEmails) - ? request.ccEmails.join(',') - : request.ccEmails; + ? JSON.stringify(request.ccEmails) + : JSON.stringify([request.ccEmails]); } // Handle different file input methods if (request.file) { // File upload - use multipart form - const response = await client.uploadFile<{ data: PrepareForSigningSingleResponse }>( + const response = await client.uploadFile( '/turbosign/single/prepare-for-signing', request.file, 'file', formData ); - return response.data; + return response; } else { // URL, deliverable, or template - use JSON body if (request.fileLink) formData.fileLink = request.fileLink; if (request.deliverableId) formData.deliverableId = request.deliverableId; if (request.templateId) formData.templateId = request.templateId; - const response = await client.post<{ data: PrepareForSigningSingleResponse }>( + const response = await client.post( '/turbosign/single/prepare-for-signing', formData ); - return response.data; + return response; } } // ============================================ - // MULTI-STEP WORKFLOW METHODS + // DOCUMENT MANAGEMENT // ============================================ - /** - * Step 1: Upload a document for signing - * - * @param file - PDF file to upload (File or Buffer) - * @param name - Optional custom name for the document - * @param description - Optional description for the document - * @returns Document upload response with documentId - * - * @example - * ```typescript - * const upload = await TurboSign.uploadDocument(pdfFile, 'Contract.pdf'); - * console.log(upload.documentId); - * ``` - */ - static async uploadDocument( - file: File | Buffer, - name?: string, - description?: string - ): Promise { - const client = this.getClient(); - const additionalData: Record = {}; - - if (name) { - additionalData.name = name; - } - if (description) { - additionalData.description = description; - } - - const response = await client.uploadFile<{ data: UploadDocumentResponse }>( - '/turbosign/documents/upload', - file, - 'file', - additionalData - ); - return response.data; - } - - /** - * Create a signature document from an existing deliverable - * - * @param deliverableId - ID of the deliverable to create a signature document from - * @param name - Optional custom name for the document - * @param description - Optional description for the document - * @returns Document upload response with documentId - * - * @example - * ```typescript - * const upload = await TurboSign.createFromDeliverable('deliverable-id', 'Contract.pdf'); - * console.log(upload.documentId); - * ``` - */ - static async createFromDeliverable( - deliverableId: string, - name?: string, - description?: string - ): Promise { - const client = this.getClient(); - const body: Record = { deliverableId }; - - if (name) { - body.name = name; - } - if (description) { - body.description = description; - } - - const response = await client.post<{ data: UploadDocumentResponse }>( - '/turbosign/documents/from-deliverable', - body - ); - return response.data; - } - - /** - * Step 2: Add recipients to the document - * - * @param documentId - ID of the uploaded document - * @param recipients - Array of recipients who will sign - * @returns Recipients with their IDs and sign URLs - * - * @example - * ```typescript - * const recipients = await TurboSign.addRecipients(documentId, [ - * { email: 'john@example.com', name: 'John Doe', order: 1 }, - * { email: 'jane@example.com', name: 'Jane Smith', order: 2 } - * ]); - * ``` - */ - static async addRecipients( - documentId: string, - recipients: AddRecipientsRequest['recipients'] - ): Promise { - const client = this.getClient(); - const response = await client.post<{ data: AddRecipientsResponse }>( - `/turbosign/documents/${documentId}/update-with-recipients`, - { document: {}, recipients } - ); - return response.data; - } - - /** - * Step 3: Prepare document for signing by placing signature fields - * - * Note: Webhooks are configured at the organization level using the Webhooks module, - * not per-signature request. See Webhooks.create() to set up webhooks. - * - * @param documentId - ID of the document - * @param request - Signature fields and configuration - * @returns Document ready for signing with recipient sign URLs - * - * @example - * ```typescript - * // Using coordinate-based positioning - * const result = await TurboSign.prepareForSigning(documentId, { - * fields: [ - * { - * type: 'signature', - * recipientId: recipients[0].id, - * page: 1, - * x: 100, - * y: 200, - * width: 200, - * height: 50 - * }, - * { - * type: 'date', - * recipientId: recipients[0].id, - * page: 1, - * x: 100, - * y: 300 - * } - * ], - * sendEmails: true - * }); - * ``` - */ - static async prepareForSigning( - documentId: string, - request: PrepareSigningRequest - ): Promise { - const client = this.getClient(); - const response = await client.post<{ data: PrepareSigningResponse }>( - `/turbosign/documents/${documentId}/prepare-for-signing`, - request.fields - ); - return response.data; - } - - /** - * Complete workflow: Upload, add recipients, and prepare in one call - * - * Note: Webhooks are configured at the organization level using the Webhooks module, - * not per-signature request. See Webhooks.create() to set up webhooks. - * - * @param file - PDF file to upload - * @param recipients - Recipients who will sign - * @param fields - Signature fields configuration - * @param options - Additional options (message, sendEmails) - * @returns Prepared document with sign URLs - * - * @example - * ```typescript - * const result = await TurboSign.createSignatureRequest({ - * file: pdfFile, - * recipients: [ - * { email: 'john@example.com', name: 'John Doe' } - * ], - * fields: [ - * { type: 'signature', recipientId: 'will-be-assigned', page: 1, x: 100, y: 200 } - * ], - * message: 'Please sign this document' - * }); - * ``` - */ - static async createSignatureRequest(params: { - file: File | Buffer; - fileName?: string; - recipients: AddRecipientsRequest['recipients']; - fields: PrepareSigningRequest['fields']; - message?: string; - sendEmails?: boolean; - }): Promise { - // Step 1: Upload document - const upload = await this.uploadDocument(params.file, params.fileName); - - // Step 2: Add recipients - const recipientsResponse = await this.addRecipients( - upload.documentId, - params.recipients - ); - - // Step 3: Map recipient emails to IDs for fields - const recipientMap = new Map( - recipientsResponse.recipients.map(r => [r.email, r.id]) - ); - - const fieldsWithRecipientIds = params.fields.map(field => { - // If recipientId is already set and valid, use it - if (field.recipientId && recipientMap.has(field.recipientId)) { - return field; - } - - // Otherwise, try to find recipient by matching order or use first recipient - const recipient = recipientsResponse.recipients[0]; - return { - ...field, - recipientId: recipient.id - }; - }); - - // Step 4: Prepare for signing - return await this.prepareForSigning(upload.documentId, { - fields: fieldsWithRecipientIds as PrepareSigningRequest['fields'], - message: params.message, - sendEmails: params.sendEmails, - }); - } - - /** - * ✨ Magical one-liner: Send a document for signature - * - * This is the simplest way to get a document signed. Just provide your file, - * recipients, and fields - we handle all the complexity for you! - * - * **Key Features:** - * - 🎨 Auto-generates beautiful recipient colors - * - 📋 Signing order based on array position (no manual ordering needed) - * - 📏 Smart field size defaults based on type - * - 📄 Auto-extracts document name from filename - * - ✉️ Sends emails by default (sendEmails: true) - * - 🎯 Use recipientEmail OR recipientIndex in fields (no manual ID mapping!) - * - * **Note:** Webhooks are configured at the organization level using the Webhooks module. - * See Webhooks.create() to set up event notifications. - * - * @param request - Send document request with file, recipients, and fields - * @returns Document ready for signing with recipient sign URLs - * - * @example - * ```typescript - * // The simplest possible signature request - * const result = await TurboSign.send({ - * file: pdfFile, - * recipients: [ - * { email: 'john@example.com', name: 'John Doe' }, - * { email: 'jane@example.com', name: 'Jane Smith' } - * ], - * fields: [ - * { type: 'signature', page: 1, x: 100, y: 650, recipientIndex: 0 }, - * { type: 'date', page: 1, x: 100, y: 600, recipientIndex: 0 }, - * { type: 'signature', page: 1, x: 350, y: 650, recipientIndex: 1 } - * ] - * }); - * - * console.log(result.recipients[0].signUrl); - * ``` - * - * @example - * ```typescript - * // Using recipientEmail instead of recipientIndex - * const result = await TurboSign.send({ - * file: pdfFile, - * fileName: 'Partnership Agreement', - * description: 'Q1 2024 Partnership Agreement', - * recipients: [ - * { email: 'ceo@company.com', name: 'Jane CEO' }, - * { email: 'legal@partner.com', name: 'John Legal' } - * ], - * fields: [ - * { - * type: 'signature', - * page: 1, - * x: 100, - * y: 500, - * recipientEmail: 'ceo@company.com' - * }, - * { - * type: 'signature', - * page: 2, - * x: 100, - * y: 500, - * recipientEmail: 'legal@partner.com' - * } - * ] - * }); - * ``` - * - * @example - * ```typescript - * // With custom recipient colors - * const result = await TurboSign.send({ - * file: pdfFile, - * recipients: [ - * { - * email: 'john@example.com', - * name: 'John Doe', - * color: 'hsl(200, 75%, 50%)', - * lightColor: 'hsl(200, 75%, 93%)' - * } - * ], - * fields: [ - * { type: 'signature', page: 1, x: 100, y: 650, recipientIndex: 0 } - * ] - * }); - * ``` - */ - static async send(request: SendDocumentRequest): Promise { - // Step 1: Upload document - const fileName = extractFileName(request.file, request.fileName); - const upload = await this.uploadDocument( - request.file, - fileName, - request.description - ); - - // Step 2: Prepare recipients with auto-generated colors and signing order - const recipientsWithMetadata = request.recipients.map((recipient, index) => { - const colors = recipient.color && recipient.lightColor - ? { color: recipient.color, lightColor: recipient.lightColor } - : generateRecipientColors(index); - - return { - name: recipient.name, - email: recipient.email, - signingOrder: index + 1, // Array index determines signing order - metadata: colors - }; - }); - - // Step 3: Save document details with recipients - const documentDetails = await this.saveDocumentDetails( - upload.documentId, - { - name: fileName, - description: request.description - }, - recipientsWithMetadata - ); - - // Step 4: Create maps for recipient lookup - const recipientEmailToId = new Map( - documentDetails.recipients.map(r => [r.email, r.id!]) - ); - const recipientIndexToId = new Map( - documentDetails.recipients.map((r, idx) => [idx, r.id!]) - ); - - // Step 5: Normalize fields (apply defaults, map recipients) - const normalizedFields = normalizeFields( - request.fields, - recipientEmailToId, - recipientIndexToId - ); - - // Step 6: Prepare for signing - const prepared = await this.prepareForSigning(upload.documentId, { - fields: normalizedFields, - message: request.message, - sendEmails: request.sendEmails !== false // Default to true - }); - - // Step 7: Return enriched response with color information - return { - documentId: prepared.documentId, - status: prepared.status, - recipients: prepared.recipients.map((recipient, index) => { - const colors = request.recipients[index]?.color && request.recipients[index]?.lightColor - ? { - color: request.recipients[index].color!, - lightColor: request.recipients[index].lightColor! - } - : generateRecipientColors(index); - - return { - id: recipient.id, - email: recipient.email, - name: recipient.name, - signingOrder: index + 1, - status: recipient.status, - signUrl: recipient.signUrl || '', - color: colors.color, - lightColor: colors.lightColor - }; - }), - preparedAt: prepared.preparedAt - }; - } - /** * Void a document (cancel signature request) * @@ -680,18 +269,30 @@ export class TurboSign { * Download the signed document * * @param documentId - ID of the document - * @returns Response with the PDF file + * @returns Response with the PDF file as Blob * * @example * ```typescript - * const response = await TurboSign.download(documentId); + * const blob = await TurboSign.download(documentId); * // Save to file or process the PDF * ``` */ static async download(documentId: string): Promise { const client = this.getClient(); - const response = await client.get(`/turbosign/documents/${documentId}/download`); - return response; + // Step 1: Get the presigned URL from the API + const response = await client.get<{ downloadUrl: string; fileName: string }>( + `/turbosign/documents/${documentId}/download` + ); + + // Step 2: Fetch the actual file from S3 + const fileResponse = await fetch(response.downloadUrl); + if (!fileResponse.ok) { + throw new Error(`Failed to download file: ${fileResponse.statusText}`); + } + + // Step 3: Return as Blob + const arrayBuffer = await fileResponse.arrayBuffer(); + return new Blob([arrayBuffer], { type: 'application/pdf' }); } /** @@ -711,303 +312,4 @@ export class TurboSign { const response = await client.get<{ data: DocumentStatusResponse }>(`/turbosign/documents/${documentId}/status`); return response.data; } - - /** - * Get document with recipients including their metadata (colors, etc.) - * - * @param documentId - ID of the document - * @returns Document with recipients and metadata - * - * @example - * ```typescript - * const docWithRecipients = await TurboSign.getDocumentWithRecipients(documentId); - * console.log(docWithRecipients.recipients[0].metadata?.color); - * ``` - */ - static async getDocumentWithRecipients(documentId: string): Promise { - const client = this.getClient(); - const response = await client.get<{ data: DocumentWithRecipients }>( - `/turbosign/documents/${documentId}/with-recipients` - ); - return response.data; - } - - /** - * Get document file as Blob and Uint8Array - * - * @param documentId - ID of the document - * @returns Document file as blob and uint8array - * - * @example - * ```typescript - * const file = await TurboSign.getDocumentFile(documentId); - * // Use file.fileAsBlob or file.fileAsUint8Array - * ``` - */ - static async getDocumentFile(documentId: string): Promise { - const client = this.getClient(); - const response = await client.get(`/turbosign/documents/${documentId}/file`); - const arrayBuffer = response as unknown as ArrayBuffer; - const uint8Array = new Uint8Array(arrayBuffer); - const blob = new Blob([uint8Array], { type: 'application/pdf' }); - - return { - fileAsBlob: blob, - fileAsUint8Array: uint8Array, - }; - } - - /** - * Get all fields for a document (authenticated endpoint for document owner/QA) - * - * @param documentId - ID of the document - * @returns Array of fields with recipient information - * - * @example - * ```typescript - * const fields = await TurboSign.getDocumentFields(documentId); - * fields.forEach(field => console.log(field.type, field.recipient?.name)); - * ``` - */ - static async getDocumentFields(documentId: string): Promise { - const client = this.getClient(); - const response = await client.get<{ data: DocumentFieldResponse[] }>( - `/turbosign/documents/${documentId}/fields` - ); - return response.data; - } - - /** - * Get all signature documents for the organization dashboard - * - * @returns Array of signature documents with recipients - * - * @example - * ```typescript - * const { documents } = await TurboSign.getSignatureDocuments(); - * documents.forEach(doc => console.log(doc.name, doc.status)); - * ``` - */ - static async getSignatureDocuments(): Promise<{ documents: SignatureDocumentListItem[] }> { - const client = this.getClient(); - const response = await client.get<{ data: { documents: SignatureDocumentListItem[] } }>( - '/turbosign/documents/signature-documents' - ); - return response.data; - } - - /** - * Download the public key for a document - * - * @param documentId - ID of the document - * @returns Public key as string - * - * @example - * ```typescript - * const publicKey = await TurboSign.downloadDocumentPublicKey(documentId); - * console.log(publicKey); - * ``` - */ - static async downloadDocumentPublicKey(documentId: string): Promise { - const client = this.getClient(); - const response = await client.get( - `/turbosign/documents/${documentId}/public-key/download` - ); - return response; - } - - /** - * Save document details with recipients (combines document update and recipient management) - * - * @param documentId - ID of the document - * @param documentData - Document name and description - * @param recipients - Recipients to add to the document - * @returns Updated document with recipients - * - * @example - * ```typescript - * const result = await TurboSign.saveDocumentDetails( - * documentId, - * { name: 'Updated Contract', description: 'Q4 2024' }, - * [{ email: 'john@example.com', name: 'John Doe', signingOrder: 1 }] - * ); - * ``` - */ - static async saveDocumentDetails( - documentId: string, - documentData: { name?: string; description?: string }, - recipients: Array<{ - name: string; - email: string; - signingOrder: number; - metadata?: { color?: string; lightColor?: string }; - }> - ): Promise { - const client = this.getClient(); - const response = await client.post<{ data: DocumentWithRecipients }>( - `/turbosign/documents/${documentId}/update-with-recipients`, - { document: documentData, recipients } - ); - return response.data; - } - - // ============================================ - // PUBLIC ENDPOINTS (for recipient signing) - // ============================================ - - /** - * Get document file using recipient token (public endpoint) - * - * @param documentId - ID of the document - * @param recipientToken - Token for the recipient - * @returns Document file as blob and uint8array - * - * @example - * ```typescript - * const file = await TurboSign.getDocumentFileWithRecipientToken(documentId, token); - * ``` - */ - static async getDocumentFileWithRecipientToken( - documentId: string, - recipientToken: string - ): Promise { - const client = this.getClient(); - const response = await client.get( - `/turbosign/public/documents/${documentId}/file?recipientToken=${recipientToken}` - ); - const arrayBuffer = response as unknown as ArrayBuffer; - const uint8Array = new Uint8Array(arrayBuffer); - const blob = new Blob([uint8Array], { type: 'application/pdf' }); - - return { - fileAsBlob: blob, - fileAsUint8Array: uint8Array, - }; - } - - /** - * Get fields for a recipient to sign using recipient token (public endpoint) - * - * @param documentId - ID of the document - * @param recipientToken - Token for the recipient - * @returns Array of fields to be signed - * - * @example - * ```typescript - * const fields = await TurboSign.getRecipientFieldsWithToken(documentId, token); - * ``` - */ - static async getRecipientFieldsWithToken( - documentId: string, - recipientToken: string - ): Promise { - const client = this.getClient(); - const response = await client.get<{ data: RecipientFieldResponse[] }>( - `/turbosign/public/documents/${documentId}/fields/recipient?recipientToken=${recipientToken}` - ); - return response.data; - } - - /** - * Record user consent to terms of service (public endpoint) - * - * @param documentId - ID of the document - * @param recipientToken - Token for the recipient - * @returns Success status - * - * @example - * ```typescript - * await TurboSign.recordTermsOfServiceConsent(documentId, token); - * ``` - */ - static async recordTermsOfServiceConsent( - documentId: string, - recipientToken: string - ): Promise<{ success: boolean }> { - const client = this.getClient(); - const response = await client.post<{ data: { success: boolean } }>( - `/turbosign/public/documents/${documentId}/consent?recipientToken=${recipientToken}`, - {} - ); - return response.data; - } - - /** - * Submit a signed document with field values using recipient token (public endpoint) - * - * @param documentId - ID of the document - * @param recipientToken - Token for the recipient - * @param fieldValues - Field values to submit - * @returns Submission response - * - * @example - * ```typescript - * const result = await TurboSign.submitSignedDocumentWithToken( - * documentId, - * token, - * [ - * { fieldId: 'field-1', value: 'signature-data-url', isTextSignature: false }, - * { fieldId: 'field-2', value: 'John Doe', isTextSignature: true, fontFamily: 'Arial' } - * ] - * ); - * ``` - */ - static async submitSignedDocumentWithToken( - documentId: string, - recipientToken: string, - fieldValues?: Array<{ - fieldId: string; - value: string; - isTextSignature?: boolean; - fontFamily?: string; - }> - ): Promise { - const client = this.getClient(); - const response = await client.post( - `/turbosign/public/documents/${documentId}/sign?recipientToken=${recipientToken}`, - fieldValues || [] - ); - return response; - } - - /** - * Get public document status using recipient token (public endpoint) - * - * @param documentId - ID of the document - * @param recipientToken - Token for the recipient - * @returns Document status and signability - * - * @example - * ```typescript - * const status = await TurboSign.getPublicDocumentStatus(documentId, token); - * if (!status.isSignable) { - * console.error(status.error); - * } - * ``` - */ - static async getPublicDocumentStatus( - documentId: string, - recipientToken: string - ): Promise { - const client = this.getClient(); - const response = await client.get<{ data: { status: string } }>( - `/turbosign/public/documents/${documentId}/status?recipientToken=${recipientToken}` - ); - - const status = response.data.status; - const isSignable = status !== 'completed' && status !== 'voided'; - - let error: string | undefined; - if (status === 'completed') { - error = 'This document has already been completed and cannot be signed again.'; - } else if (status === 'voided') { - error = 'This document has been voided and is no longer valid for signing.'; - } - - return { - status, - isSignable, - error, - }; - } } diff --git a/packages/js-sdk/src/modules/webhooks.ts b/packages/js-sdk/src/modules/webhooks.ts deleted file mode 100644 index 05b16561..00000000 --- a/packages/js-sdk/src/modules/webhooks.ts +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Webhooks Module - Organization-wide webhook configuration for events - * - * Webhooks are configured at the organization level and apply to ALL signature events. - * You cannot set webhooks per-signature request - configure them once here. - */ - -import { HttpClient, HttpClientConfig } from '../http'; -import { - CreateWebhookRequest, - UpdateWebhookRequest, - Webhook, - WebhookWithSecret, - WebhookStats, - WebhookDelivery, - ListWebhooksOptions, - ListWebhooksResponse, - ListDeliveriesOptions, - ListDeliveriesResponse, - TestWebhookResult, - RegeneratedSecret, - WebhookEvent -} from '../types/webhooks'; - -export class Webhooks { - private static client: HttpClient; - - /** - * Configure the Webhooks module with authentication - * - * @param config - Configuration with API key or access token - * - * @example - * ```typescript - * Webhooks.configure({ apiKey: 'your-api-key' }); - * ``` - */ - static configure(config: HttpClientConfig): void { - this.client = new HttpClient(config); - } - - /** - * Get configured HTTP client - */ - private static getClient(): HttpClient { - if (!this.client) { - throw new Error('Webhooks module not configured. Call Webhooks.configure() first.'); - } - return this.client; - } - - // ============================================ - // CORE CRUD OPERATIONS - // ============================================ - - /** - * List all webhooks for the organization - * - * @param options - Pagination and filter options - * @returns List of webhooks with stats - * - * @example - * ```typescript - * const webhooks = await Webhooks.list({ limit: 10, isActive: true }); - * console.log(`Found ${webhooks.totalRecords} webhooks`); - * ``` - */ - static async list(options?: ListWebhooksOptions): Promise { - const client = this.getClient(); - const response = await client.get<{ data: ListWebhooksResponse }>('/api/webhooks', options); - return response.data; - } - - /** - * Create a new webhook - * - * **IMPORTANT**: The secret is only returned ONCE on creation. Save it securely! - * - * @param name - Unique webhook name - * @param urls - Array of HTTPS URLs (max 10) - * @param events - Events to subscribe to - * @returns Webhook with secret (save the secret - won't be shown again!) - * - * @example - * ```typescript - * const webhook = await Webhooks.create( - * 'signature-webhook', - * ['https://your-app.com/webhooks/turbosign'], - * [WebhookEvent.SIGNATURE_DOCUMENT_COMPLETED] - * ); - * - * // IMPORTANT: Save the secret securely! - * console.log('Webhook Secret (save this!):', webhook.secret); - * ``` - */ - static async create( - name: string, - urls: string[], - events: WebhookEvent[] - ): Promise { - const client = this.getClient(); - - // Validate HTTPS URLs - for (const url of urls) { - if (!url.startsWith('https://')) { - throw new Error(`All webhook URLs must use HTTPS. Invalid URL: ${url}`); - } - } - - const response = await client.post<{ data: WebhookWithSecret }>('/api/webhooks', { - name, - urls, - events - }); - - return response.data; - } - - /** - * Get webhook details by name - * - * @param name - Webhook name - * @returns Webhook details with delivery statistics - * - * @example - * ```typescript - * const webhook = await Webhooks.get('signature-webhook'); - * console.log('Webhook URLs:', webhook.urls); - * console.log('Subscribed events:', webhook.events); - * ``` - */ - static async get(name: string): Promise { - const client = this.getClient(); - const response = await client.get<{ data: Webhook }>(`/api/webhooks/${name}`); - return response.data; - } - - /** - * Update an existing webhook - * - * @param name - Webhook name to update - * @param updates - Fields to update - * @returns Updated webhook - * - * @example - * ```typescript - * // Add a new URL - * const updated = await Webhooks.update('signature-webhook', { - * urls: [ - * 'https://your-app.com/webhooks/turbosign', - * 'https://backup.your-app.com/webhooks/turbosign' - * ] - * }); - * ``` - * - * @example - * ```typescript - * // Disable webhook temporarily - * await Webhooks.update('signature-webhook', { isActive: false }); - * ``` - */ - static async update(name: string, updates: UpdateWebhookRequest): Promise { - const client = this.getClient(); - - // Validate HTTPS URLs if updating URLs - if (updates.urls) { - for (const url of updates.urls) { - if (!url.startsWith('https://')) { - throw new Error(`All webhook URLs must use HTTPS. Invalid URL: ${url}`); - } - } - } - - const response = await client.patch<{ data: Webhook }>(`/api/webhooks/${name}`, updates); - return response.data; - } - - /** - * Delete a webhook - * - * @param name - Webhook name to delete - * @returns Deletion confirmation - * - * @example - * ```typescript - * await Webhooks.delete('old-webhook'); - * console.log('Webhook deleted successfully'); - * ``` - */ - static async delete(name: string): Promise<{ message: string }> { - const client = this.getClient(); - return await client.delete(`/api/webhooks/${name}`); - } - - // ============================================ - // SECURITY OPERATIONS - // ============================================ - - /** - * Regenerate webhook secret - * - * **IMPORTANT**: The new secret is only returned ONCE. Save it securely! - * This invalidates the old secret immediately. - * - * @param name - Webhook name - * @returns New secret (save this - won't be shown again!) - * - * @example - * ```typescript - * const { secret } = await Webhooks.regenerateSecret('signature-webhook'); - * console.log('New secret (save this!):', secret); - * // Update your webhook verification code with the new secret - * ``` - */ - static async regenerateSecret(name: string): Promise { - const client = this.getClient(); - const response = await client.post<{ data: RegeneratedSecret }>( - `/api/webhooks/${name}/regenerate`, - {} - ); - return response.data; - } - - // ============================================ - // TESTING & MONITORING - // ============================================ - - /** - * Test a webhook by sending a test event - * - * @param name - Webhook name to test - * @param eventType - Optional specific event type to test - * @param payload - Optional custom test payload - * @returns Test delivery results - * - * @example - * ```typescript - * // Test with default event - * const result = await Webhooks.test('signature-webhook'); - * console.log(`Sent to ${result.summary.total} URLs`); - * console.log(`Successful: ${result.summary.successful}`); - * console.log(`Failed: ${result.summary.failed}`); - * ``` - * - * @example - * ```typescript - * // Test specific event with custom payload - * const result = await Webhooks.test( - * 'signature-webhook', - * WebhookEvent.SIGNATURE_DOCUMENT_COMPLETED, - * { documentId: 'test-123', testMode: true } - * ); - * ``` - */ - static async test( - name: string, - eventType?: WebhookEvent, - payload?: Record - ): Promise { - const client = this.getClient(); - const response = await client.post<{ data: TestWebhookResult }>( - `/api/webhooks/${name}/test`, - { eventType, payload } - ); - return response.data; - } - - /** - * Get webhook delivery attempts - * - * @param name - Webhook name - * @param options - Filter and pagination options - * @returns List of delivery attempts - * - * @example - * ```typescript - * // Get recent failed deliveries - * const deliveries = await Webhooks.getDeliveries('signature-webhook', { - * isDelivered: false, - * limit: 20 - * }); - * - * deliveries.results.forEach(delivery => { - * console.log(`Failed delivery: ${delivery.errorMessage}`); - * }); - * ``` - */ - static async getDeliveries( - name: string, - options?: ListDeliveriesOptions - ): Promise { - const client = this.getClient(); - const response = await client.get<{ data: ListDeliveriesResponse }>( - `/api/webhooks/${name}/deliveries`, - options - ); - return response.data; - } - - /** - * Replay a failed webhook delivery - * - * @param name - Webhook name - * @param deliveryId - ID of the delivery to replay - * @returns New delivery attempt - * - * @example - * ```typescript - * // Get failed deliveries and replay them - * const deliveries = await Webhooks.getDeliveries('signature-webhook', { - * isDelivered: false - * }); - * - * for (const delivery of deliveries.results) { - * console.log(`Replaying failed delivery ${delivery.id}...`); - * await Webhooks.replayDelivery('signature-webhook', delivery.id); - * } - * ``` - */ - static async replayDelivery(name: string, deliveryId: string): Promise { - const client = this.getClient(); - const response = await client.post<{ data: WebhookDelivery }>( - `/api/webhooks/${name}/replay`, - { deliveryId } - ); - return response.data; - } - - /** - * Get detailed webhook statistics - * - * @param name - Webhook name - * @param days - Number of days to include in stats (default: 30) - * @returns Detailed statistics - * - * @example - * ```typescript - * const stats = await Webhooks.getStats('signature-webhook', 7); - * - * console.log(`Success rate: ${stats.summary.successRate}%`); - * console.log(`Avg response time: ${stats.summary.avgResponseTime}ms`); - * - * stats.eventBreakdown.forEach(event => { - * console.log(`${event.eventType}: ${event.total} deliveries (${event.successRate}% success)`); - * }); - * ``` - */ - static async getStats(name: string, days: number = 30): Promise { - const client = this.getClient(); - const response = await client.get<{ data: WebhookStats }>( - `/api/webhooks/${name}/stats`, - { days } - ); - return response.data; - } - - /** - * Send manual notification to webhook - * - * Useful for triggering custom events or testing specific scenarios - * - * @param name - Webhook name - * @param eventType - Event type to send - * @param payload - Custom event payload - * @returns Delivery result - * - * @example - * ```typescript - * await Webhooks.sendNotification( - * 'signature-webhook', - * WebhookEvent.SIGNATURE_DOCUMENT_COMPLETED, - * { - * documentId: 'doc-123', - * completedAt: new Date().toISOString(), - * customData: { source: 'manual-trigger' } - * } - * ); - * ``` - */ - static async sendNotification( - name: string, - eventType: WebhookEvent, - payload: Record - ): Promise { - const client = this.getClient(); - const response = await client.post<{ data: TestWebhookResult }>( - `/api/webhooks/${name}/notify`, - { eventType, payload } - ); - return response.data; - } -} diff --git a/packages/js-sdk/src/types/sign.ts b/packages/js-sdk/src/types/sign.ts index 393ce0fc..bfd6e239 100644 --- a/packages/js-sdk/src/types/sign.ts +++ b/packages/js-sdk/src/types/sign.ts @@ -12,62 +12,20 @@ export type SignatureFieldType = | 'company' | 'first_name' | 'last_name' - | 'email'; + | 'email' + | 'checkbox'; -export interface SignatureField { - /** Type of signature field */ - type: SignatureFieldType; - /** ID of the recipient this field is assigned to */ - recipientId: string; - /** Page number (1-indexed) */ - page: number; - /** X coordinate position on the page */ - x: number; - /** Y coordinate position on the page */ - y: number; - /** Width of the field in points (required for coordinate-based fields) */ - width: number; - /** Height of the field in points (required for coordinate-based fields) */ - height: number; - /** Page width in points (required for coordinate-based fields) */ - pageWidth: number; - /** Page height in points (required for coordinate-based fields) */ - pageHeight: number; - /** Default value for the field */ - defaultValue?: string; - /** Whether this is a multiline text field */ - isMultiline?: boolean; - /** Whether this field is required */ - required?: boolean; - /** Label for the field */ - label?: string; -} - -export interface TemplateField { - /** Template field name/identifier */ - name: string; - /** Type of signature field */ - type: SignatureFieldType; - /** ID of the recipient this field is assigned to */ - recipientId: string; - /** Whether this field is required */ - required?: boolean; -} +// ============================================ +// RESPONSE TYPES +// ============================================ -export interface Recipient { +export interface RecipientResponse { + /** Unique ID for this recipient */ + id: string; /** Recipient's email address */ email: string; /** Recipient's full name */ name: string; - /** Signing order (optional, for sequential signing) */ - order?: number; - /** Custom message for this recipient */ - message?: string; -} - -export interface RecipientResponse extends Recipient { - /** Unique ID for this recipient */ - id: string; /** Current status of the recipient */ status: 'pending' | 'completed' | 'declined'; /** URL for the recipient to sign the document */ @@ -76,49 +34,6 @@ export interface RecipientResponse extends Recipient { signedAt?: string; } -export interface UploadDocumentResponse { - /** Unique document ID */ - documentId: string; - /** Document name */ - name: string; - /** Number of pages in the document */ - pageCount: number; - /** Document status */ - status: string; -} - -export interface AddRecipientsRequest { - /** List of recipients */ - recipients: Recipient[]; -} - -export interface AddRecipientsResponse { - /** Document ID */ - documentId: string; - /** List of recipients with their IDs and sign URLs */ - recipients: RecipientResponse[]; -} - -export interface PrepareSigningRequest { - /** List of signature fields to place on the document */ - fields: SignatureField[] | TemplateField[]; - /** Custom message to all signers */ - message?: string; - /** Whether to send email notifications immediately */ - sendEmails?: boolean; -} - -export interface PrepareSigningResponse { - /** Document ID */ - documentId: string; - /** Status of the document */ - status: 'prepared' | 'sent'; - /** List of recipients with their sign URLs */ - recipients: RecipientResponse[]; - /** When the document was prepared */ - preparedAt: string; -} - export interface VoidDocumentResponse { /** Document ID */ documentId: string; @@ -174,233 +89,12 @@ export interface DocumentStatusResponse { completedAt?: string; } -export interface RecipientMetadata { - /** UI color for recipient */ - color?: string; - /** Light variant color */ - lightColor?: string; -} - -export interface DocumentWithRecipients { - /** Document details */ - document: { - id: string; - name: string; - description: string; - pdfFileId?: string; - status: DocumentStatusResponse['status']; - createdOn: string; - accessToken?: string; - }; - /** Recipients with metadata */ - recipients: Array<{ - id?: string; - documentId?: string; - name: string; - email: string; - signingOrder: number; - status?: string; - accessToken?: string; - signedOn?: Date; - metadata?: RecipientMetadata; - }>; - /** Optional description */ - description?: string; -} - -export interface DocumentFileResponse { - /** File as Blob */ - fileAsBlob: Blob; - /** File as Uint8Array */ - fileAsUint8Array: Uint8Array; -} - -export interface DocumentFieldResponse { - id: string; - type: string; - page: number; - x: number; - y: number; - width: number; - height: number; - pageWidth: number; - pageHeight: number; - recipientId: string; - value?: string; - templateData?: any; - calculatedFromTemplate?: boolean; - recipient?: { - name: string; - email: string; - metadata?: RecipientMetadata; - }; -} - -export interface SignatureDocumentListItem { - id: string; - name: string; - description: string; - status: DocumentStatusResponse['status']; - pdfFileId: string; - createdBy: string; - createdOn: string; - updatedOn: string; - recipients: Array<{ - id: string; - name: string; - email: string; - status: string; - signingOrder: number; - }>; - metadata?: { - senderName?: string; - }; -} - -export interface RecipientFieldResponse { - id: string; - type: string; - page: number; - position: { x: number; y: number }; - size: { width: number; height: number }; - pageWidth: number; - pageHeight: number; - defaultValue?: string; - value?: string | null; - isMultiline?: boolean; -} - -export interface SubmitSignedDocumentResponse { - message: string; - isFreePlan: boolean; -} - -export interface PublicDocumentStatusResponse { - status: string; - isSignable: boolean; - error?: string; -} - -// ============================================ -// SIMPLIFIED API TYPES (for TurboSign.send) -// ============================================ - -/** - * Simplified recipient - no manual order assignment needed - * Signing order is automatically determined by array position - */ -export interface SimplifiedRecipient { - /** Recipient's email address */ - email: string; - /** Recipient's full name */ - name: string; - /** Optional custom message for this recipient */ - message?: string; - /** Optional custom color (auto-generated if not provided) */ - color?: string; - /** Optional light variant color (auto-generated if not provided) */ - lightColor?: string; -} - -/** - * Simplified field supporting multiple ways to specify recipient - */ -export type SimplifiedField = { - /** Field type */ - type: SignatureFieldType; - /** Page number (1-indexed) */ - page: number; - /** Whether field is required (default: true) */ - required?: boolean; - /** Default value */ - defaultValue?: string; - /** Label for the field */ - label?: string; - /** Whether this is a multiline text field */ - isMultiline?: boolean; -} & ( - | { - /** Template-based positioning using text anchors */ - anchor: string; - /** Placement relative to anchor */ - placement?: 'replace' | 'before' | 'after'; - /** Size of the field */ - size?: { width: number; height: number }; - /** Offset from anchor position */ - offset?: { x: number; y: number }; - /** Case sensitive anchor search */ - caseSensitive?: boolean; - /** Use regex for anchor */ - useRegex?: boolean; - } - | { - /** Coordinate-based positioning */ - x: number; - y: number; - /** Field width (auto-filled with defaults if not provided) */ - width?: number; - /** Field height (auto-filled with defaults if not provided) */ - height?: number; - /** Page width (auto-detected from PDF or defaults to 612) */ - pageWidth?: number; - /** Page height (auto-detected from PDF or defaults to 792) */ - pageHeight?: number; - } -) & - ( - | { recipientEmail: string } - | { recipientIndex: number } - ); - -/** - * Request for the magical TurboSign.send() method - */ -export interface SendDocumentRequest { - /** PDF file to send for signature */ - file: File | Buffer; - /** Recipients who will sign (order in array determines signing order) */ - recipients: SimplifiedRecipient[]; - /** Signature fields to place on document */ - fields: SimplifiedField[]; - /** Document name (auto-extracted from filename if not provided) */ - fileName?: string; - /** Document description */ - description?: string; - /** Custom message to all signers */ - message?: string; - /** Whether to send emails immediately (default: true) */ - sendEmails?: boolean; -} - -/** - * Response from TurboSign.send() - */ -export interface SendDocumentResponse { - /** Document ID */ - documentId: string; - /** Document status */ - status: 'prepared' | 'sent'; - /** Recipients with their sign URLs */ - recipients: Array<{ - id: string; - email: string; - name: string; - signingOrder: number; - status: 'pending' | 'completed' | 'declined'; - signUrl: string; - color: string; - lightColor: string; - }>; - /** When the document was prepared */ - preparedAt: string; -} - // ============================================ -// N8N PARITY TYPES (single-call operations) +// SINGLE-STEP OPERATION TYPES // ============================================ /** - * Field configuration matching n8n node schema + * Field configuration for single-step operations * Supports both coordinate-based and template anchor-based positioning */ export interface N8nField { @@ -416,23 +110,39 @@ export interface N8nField { width?: number; /** Field height in pixels */ height?: number; - /** Recipient order (1-indexed) - which recipient fills this field */ - recipientOrder: number; + /** Recipient email - which recipient fills this field */ + recipientEmail: string; + /** Default value for the field (for checkbox: "true" or "false") */ + defaultValue?: string; + /** Whether this is a multiline text field */ + isMultiline?: boolean; + /** Whether this field is read-only (pre-filled, non-editable) */ + isReadonly?: boolean; + /** Whether this field is required */ + required?: boolean; + /** Background color (hex, rgb, or named colors) */ + backgroundColor?: string; /** Template anchor configuration for dynamic positioning */ template?: { - /** Text pattern to find in document */ - anchor: string; - /** Where to place field relative to anchor */ - placement: 'replace' | 'before' | 'after' | 'above' | 'below'; - /** Field width */ - width: number; - /** Field height */ - height: number; + /** Text anchor pattern like {TagName} */ + anchor?: string; + /** Alternative: search for any text in document */ + searchText?: string; + /** Where to place field relative to anchor/searchText */ + placement?: 'replace' | 'before' | 'after' | 'above' | 'below'; + /** Size of the field */ + size?: { width: number; height: number }; + /** Offset from anchor position */ + offset?: { x: number; y: number }; + /** Case sensitive search (default: false) */ + caseSensitive?: boolean; + /** Use regex for anchor/searchText (default: false) */ + useRegex?: boolean; }; } /** - * Recipient configuration for n8n operations + * Recipient configuration for single-step operations */ export interface N8nRecipient { /** Recipient's full name */ @@ -440,16 +150,16 @@ export interface N8nRecipient { /** Recipient's email address */ email: string; /** Signing order (1-indexed) */ - order: number; + signingOrder: number; } /** - * Request for prepareForReview - prepare document without sending emails + * Request for createSignatureReviewLink - prepare document without sending emails */ -export interface PrepareForReviewRequest { - /** PDF file as Buffer */ - file?: Buffer; - /** Original filename */ +export interface CreateSignatureReviewLinkRequest { + /** PDF file as file path, Buffer, or browser File */ + file?: string | File | Buffer; + /** Original filename (used when file is a Buffer) */ fileName?: string; /** URL to document file */ fileLink?: string; @@ -474,13 +184,15 @@ export interface PrepareForReviewRequest { } /** - * Response from prepareForReview + * Response from createSignatureReviewLink */ -export interface PrepareForReviewResponse { +export interface CreateSignatureReviewLinkResponse { + /** Whether the request was successful */ + success: boolean; /** Document ID */ documentId: string; /** Document status */ - status: 'review_ready' | string; + status: string; /** Preview URL for reviewing the document */ previewUrl?: string; /** Recipients with their status */ @@ -490,15 +202,17 @@ export interface PrepareForReviewResponse { email: string; status: string; }>; + /** Response message */ + message: string; } /** - * Request for prepareForSigningSingle - prepare and send in single call + * Request for sendSignature - prepare and send in single call */ -export interface PrepareForSigningSingleRequest { - /** PDF file as Buffer */ - file?: Buffer; - /** Original filename */ +export interface SendSignatureRequest { + /** PDF file as file path, Buffer, or browser File */ + file?: string | File | Buffer; + /** Original filename (used when file is a Buffer) */ fileName?: string; /** URL to document file */ fileLink?: string; @@ -523,19 +237,13 @@ export interface PrepareForSigningSingleRequest { } /** - * Response from prepareForSigningSingle + * Response from sendSignature */ -export interface PrepareForSigningSingleResponse { +export interface SendSignatureResponse { + /** Whether the request was successful */ + success: boolean; /** Document ID */ documentId: string; - /** Document status */ - status: 'sent' | string; - /** Recipients with their sign URLs */ - recipients: Array<{ - id: string; - name: string; - email: string; - status: string; - signUrl?: string; - }>; + /** Response message */ + message: string; } diff --git a/packages/js-sdk/src/types/webhooks.ts b/packages/js-sdk/src/types/webhooks.ts deleted file mode 100644 index aacedd0a..00000000 --- a/packages/js-sdk/src/types/webhooks.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * TypeScript types for Webhooks module - */ - -/** - * Available webhook events for TurboSign - */ -export enum WebhookEvent { - SIGNATURE_DOCUMENT_COMPLETED = 'signature.document.completed', - SIGNATURE_DOCUMENT_VOIDED = 'signature.document.voided' -} - -/** - * Request to create a new webhook - */ -export interface CreateWebhookRequest { - /** Webhook name (unique identifier) */ - name: string; - /** Array of HTTPS URLs to send webhooks to (max 10) */ - urls: string[]; - /** Events to subscribe to */ - events: WebhookEvent[]; -} - -/** - * Request to update an existing webhook - */ -export interface UpdateWebhookRequest { - /** New webhook name */ - name?: string; - /** Updated URLs */ - urls?: string[]; - /** Updated events */ - events?: WebhookEvent[]; - /** Whether webhook is active */ - isActive?: boolean; -} - -/** - * Webhook configuration - */ -export interface Webhook { - /** Unique webhook ID */ - id: string; - /** Webhook name */ - name: string; - /** URLs that receive webhook events */ - urls: string[]; - /** Subscribed events */ - events: string[]; - /** Whether webhook is active */ - isActive: boolean; - /** User ID who created the webhook */ - createdBy: string; - /** Creation timestamp */ - createdOn: string; - /** Last update timestamp */ - updatedOn: string; - /** Total delivery attempts (from list endpoint) */ - totalDeliveries?: number; - /** Successful deliveries (from list endpoint) */ - successfulDeliveries?: number; - /** Last delivery timestamp (from list endpoint) */ - lastDelivery?: string; -} - -/** - * Webhook with secret (only returned on creation or regeneration) - */ -export interface WebhookWithSecret extends Webhook { - /** Webhook secret for signature verification (only shown once!) */ - secret: string; -} - -/** - * Webhook delivery attempt - */ -export interface WebhookDelivery { - /** Delivery ID */ - id: string; - /** Event type that triggered this delivery */ - eventType: string; - /** Target URL */ - url: string; - /** HTTP status code from the webhook endpoint */ - httpStatus?: number; - /** Response body from the webhook endpoint */ - responseBody?: string; - /** Number of delivery attempts */ - attemptCount: number; - /** Maximum attempts before giving up */ - maxAttempts: number; - /** Whether delivery was successful */ - isDelivered: boolean; - /** Delivery status */ - status: string; - /** When successfully delivered */ - deliveredAt?: string; - /** Error message if delivery failed */ - errorMessage?: string; - /** When delivery was created */ - createdOn: string; - /** Last update timestamp */ - updatedOn: string; -} - -/** - * Detailed webhook statistics - */ -export interface WebhookStats { - /** Webhook information */ - webhook: { - id: string; - name: string; - isActive: boolean; - events: string[]; - urls: string[]; - }; - /** Time period for statistics */ - period: { - days: number; - from: string; - to: string; - }; - /** Summary statistics */ - summary: { - totalDeliveries: number; - successfulDeliveries: number; - failedDeliveries: number; - pendingRetries: number; - successRate: number; - avgResponseTime: number | null; - lastSuccessfulDelivery?: string; - lastFailedDelivery?: string; - }; - /** Breakdown by event type */ - eventBreakdown: Array<{ - eventType: string; - total: number; - successful: number; - failed: number; - successRate: number; - }>; -} - -/** - * Pagination and filter options for listing webhooks - */ -export interface ListWebhooksOptions { - /** Maximum number of results */ - limit?: number; - /** Number of results to skip */ - offset?: number; - /** Filter by webhook name (partial match) */ - name?: string; - /** Filter by active status */ - isActive?: boolean; -} - -/** - * Response from listing webhooks - */ -export interface ListWebhooksResponse { - /** Array of webhooks with stats */ - results: Webhook[]; - /** Total number of webhooks matching filters */ - totalRecords: number; - /** Limit used for pagination */ - limit: number; - /** Offset used for pagination */ - offset: number; -} - -/** - * Options for listing webhook deliveries - */ -export interface ListDeliveriesOptions { - /** Maximum number of results */ - limit?: number; - /** Number of results to skip */ - offset?: number; - /** Filter by event type */ - eventType?: string; - /** Filter by delivery status */ - isDelivered?: boolean; - /** Filter by HTTP status code */ - httpStatus?: number; -} - -/** - * Response from listing webhook deliveries - */ -export interface ListDeliveriesResponse { - /** Array of delivery attempts */ - results: WebhookDelivery[]; - /** Total number of deliveries matching filters */ - totalRecords: number; - /** Limit used for pagination */ - limit: number; - /** Offset used for pagination */ - offset: number; -} - -/** - * Test webhook delivery result - */ -export interface TestWebhookResult { - /** Individual delivery results for each URL */ - deliveries: WebhookDelivery[]; - /** Summary of test results */ - summary: { - total: number; - successful: number; - failed: number; - }; -} - -/** - * Regenerated webhook secret - */ -export interface RegeneratedSecret { - /** Webhook ID */ - id: string; - /** New secret (save this - won't be shown again!) */ - secret: string; - /** When secret was regenerated */ - regeneratedAt: string; -} diff --git a/packages/js-sdk/src/utils/field-helpers.ts b/packages/js-sdk/src/utils/field-helpers.ts deleted file mode 100644 index 9a5ab8c8..00000000 --- a/packages/js-sdk/src/utils/field-helpers.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Helper utilities for TurboSign field processing - */ - -import type { SignatureFieldType, SimplifiedField, SignatureField } from '../types/sign'; - -/** - * Golden ratio for aesthetically pleasing color distribution - */ -const GOLDEN_RATIO = 0.618033988749895; - -/** - * Generate a beautiful color using HSL and golden ratio - * @param index - Recipient index - * @returns Object with color and lightColor - */ -export function generateRecipientColors(index: number): { color: string; lightColor: string } { - // Use golden ratio to distribute hues evenly across color spectrum - const hue = Math.floor((index * GOLDEN_RATIO * 360) % 360); - const saturation = 75; // Vibrant colors - const lightness = 50; // Medium lightness for good contrast - - return { - color: `hsl(${hue}, ${saturation}%, ${lightness}%)`, - lightColor: `hsl(${hue}, ${saturation}%, 93%)` // Much lighter for backgrounds - }; -} - -/** - * Default field sizes based on field type - */ -const DEFAULT_FIELD_SIZES: Record = { - signature: { width: 200, height: 50 }, - initial: { width: 100, height: 50 }, - date: { width: 150, height: 30 }, - text: { width: 200, height: 30 }, - full_name: { width: 200, height: 30 }, - first_name: { width: 150, height: 30 }, - last_name: { width: 150, height: 30 }, - title: { width: 200, height: 30 }, - company: { width: 200, height: 30 }, - email: { width: 200, height: 30 } -}; - -/** - * Standard US Letter page size in points (8.5" x 11" at 72 DPI) - */ -const DEFAULT_PAGE_SIZE = { - width: 612, - height: 792 -}; - -/** - * Get default size for a field type - * @param type - Field type - * @returns Default width and height - */ -export function getDefaultFieldSize(type: SignatureFieldType): { width: number; height: number } { - return DEFAULT_FIELD_SIZES[type] || { width: 200, height: 50 }; -} - -/** - * Normalize simplified fields to full SignatureField format - * @param fields - Simplified fields from user - * @param recipientEmailToId - Map of email to recipient ID - * @param recipientIndexToId - Map of index to recipient ID - * @returns Full SignatureField array ready for API - */ -export function normalizeFields( - fields: SimplifiedField[], - recipientEmailToId: Map, - recipientIndexToId: Map -): SignatureField[] { - return fields.map(field => { - // Get recipient ID - let recipientId: string; - if ('recipientEmail' in field) { - const id = recipientEmailToId.get(field.recipientEmail); - if (!id) { - throw new Error(`Recipient with email "${field.recipientEmail}" not found`); - } - recipientId = id; - } else if ('recipientIndex' in field) { - const id = recipientIndexToId.get(field.recipientIndex); - if (!id) { - throw new Error(`Recipient at index ${field.recipientIndex} not found`); - } - recipientId = id; - } else { - throw new Error('Field must specify either recipientEmail or recipientIndex'); - } - - // Template-based field - if ('anchor' in field) { - return { - type: field.type, - recipientId, - page: field.page, - x: 0, // Will be calculated by backend - y: 0, - width: field.size?.width || getDefaultFieldSize(field.type).width, - height: field.size?.height || getDefaultFieldSize(field.type).height, - pageWidth: DEFAULT_PAGE_SIZE.width, - pageHeight: DEFAULT_PAGE_SIZE.height, - defaultValue: field.defaultValue, - required: field.required !== false, // Default to true - label: field.label, - isMultiline: field.isMultiline, - // Template data would be sent separately in actual implementation - // This is a simplification - backend handles template positioning - } as SignatureField; - } - - // Coordinate-based field - const defaultSize = getDefaultFieldSize(field.type); - return { - type: field.type, - recipientId, - page: field.page, - x: field.x, - y: field.y, - width: field.width ?? defaultSize.width, - height: field.height ?? defaultSize.height, - pageWidth: field.pageWidth ?? DEFAULT_PAGE_SIZE.width, - pageHeight: field.pageHeight ?? DEFAULT_PAGE_SIZE.height, - defaultValue: field.defaultValue, - required: field.required !== false, // Default to true - label: field.label, - isMultiline: field.isMultiline - }; - }); -} - -/** - * Extract clean file name from a path or file object - * @param file - File object or buffer - * @param providedName - User-provided name (optional) - * @returns Clean file name without extension - */ -export function extractFileName(file: any, providedName?: string): string { - if (providedName) { - // Remove .pdf extension if present - return providedName.replace(/\.pdf$/i, ''); - } - - // Try to get name from File object - if (file && typeof file === 'object' && 'name' in file) { - return (file.name as string).replace(/\.pdf$/i, ''); - } - - // Default fallback - return 'Document'; -} diff --git a/packages/js-sdk/tests/turbosign.test.ts b/packages/js-sdk/tests/turbosign.test.ts index 23f9d998..7e5e0f28 100644 --- a/packages/js-sdk/tests/turbosign.test.ts +++ b/packages/js-sdk/tests/turbosign.test.ts @@ -1,388 +1,680 @@ /** * TurboSign Module Tests * - * Tests for 100% parity with n8n-nodes-turbodocx operations: - * - prepareForReview - * - prepareForSigning (single call) + * Tests for SDK operations: + * - createSignatureReviewLink + * - sendSignature * - getStatus - * - downloadDocument - * - voidDocument - * - resendEmail + * - download + * - void + * - resend + * - getAuditTrail */ -import { TurboSign } from '../src/modules/sign'; -import { HttpClient } from '../src/http'; -import type { N8nRecipient, N8nField } from '../src/types/sign'; +import { TurboSign } from "../src/modules/sign"; +import { HttpClient } from "../src/http"; +import type { N8nRecipient, N8nField } from "../src/types/sign"; // Mock the HttpClient -jest.mock('../src/http'); +jest.mock("../src/http"); + +// Mock global fetch for download tests +const mockFetch = jest.fn(); +global.fetch = mockFetch; const MockedHttpClient = HttpClient as jest.MockedClass; -describe('TurboSign Module', () => { +describe("TurboSign Module", () => { beforeEach(() => { jest.clearAllMocks(); // Reset static client (TurboSign as any).client = undefined; }); - describe('configure', () => { - it('should configure the client with API key', () => { - TurboSign.configure({ apiKey: 'test-api-key' }); - expect(MockedHttpClient).toHaveBeenCalledWith({ apiKey: 'test-api-key' }); + describe("configure", () => { + it("should configure the client with API key", () => { + TurboSign.configure({ apiKey: "test-api-key" }); + expect(MockedHttpClient).toHaveBeenCalledWith({ apiKey: "test-api-key" }); + }); + + it("should configure with custom base URL", () => { + TurboSign.configure({ + apiKey: "test-api-key", + baseUrl: "https://custom-api.example.com", + }); + expect(MockedHttpClient).toHaveBeenCalledWith({ + apiKey: "test-api-key", + baseUrl: "https://custom-api.example.com", + }); }); - it('should configure with custom base URL', () => { + it("should configure with org ID", () => { TurboSign.configure({ - apiKey: 'test-api-key', - baseUrl: 'https://custom-api.example.com' + apiKey: "test-api-key", + orgId: "org-123", }); expect(MockedHttpClient).toHaveBeenCalledWith({ - apiKey: 'test-api-key', - baseUrl: 'https://custom-api.example.com' + apiKey: "test-api-key", + orgId: "org-123", }); }); }); - describe('prepareForReview', () => { - const mockFile = Buffer.from('mock-pdf-content'); + describe("createSignatureReviewLink", () => { + const mockFile = Buffer.from("mock-pdf-content"); const mockRecipients: N8nRecipient[] = [ - { name: 'John Doe', email: 'john@example.com', order: 1 } + { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ]; const mockFields: N8nField[] = [ - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 } + { + type: "signature", + page: 1, + x: 100, + y: 500, + width: 200, + height: 50, + recipientEmail: "john@example.com", + }, ]; - it('should prepare document for review with file upload', async () => { + it("should prepare document for review with file upload", async () => { const mockResponse = { - data: { - documentId: 'doc-123', - status: 'review_ready', - previewUrl: 'https://preview.example.com/doc-123', - recipients: [ - { id: 'rec-1', name: 'John Doe', email: 'john@example.com', status: 'pending' } - ] - } + success: true, + documentId: "doc-123", + status: "review_ready", + previewUrl: "https://preview.example.com/doc-123", + recipients: [ + { + id: "rec-1", + name: "John Doe", + email: "john@example.com", + status: "pending", + }, + ], + message: "Document prepared for review", }; - MockedHttpClient.prototype.uploadFile = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.uploadFile = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForReview({ + const result = await TurboSign.createSignatureReviewLink({ file: mockFile, recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-123'); - expect(result.status).toBe('review_ready'); + expect(result.success).toBe(true); + expect(result.documentId).toBe("doc-123"); + expect(result.status).toBe("review_ready"); expect(result.previewUrl).toBeDefined(); }); - it('should prepare document for review with file URL', async () => { + it("should prepare document for review with file URL", async () => { const mockResponse = { - data: { - documentId: 'doc-456', - status: 'review_ready', - previewUrl: 'https://preview.example.com/doc-456' - } + success: true, + documentId: "doc-456", + status: "review_ready", + previewUrl: "https://preview.example.com/doc-456", + message: "Document prepared for review", }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForReview({ - fileLink: 'https://storage.example.com/contract.pdf', + const result = await TurboSign.createSignatureReviewLink({ + fileLink: "https://storage.example.com/contract.pdf", recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-456'); + expect(result.documentId).toBe("doc-456"); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/single/prepare-for-review', + "/turbosign/single/prepare-for-review", expect.objectContaining({ - fileLink: 'https://storage.example.com/contract.pdf', + fileLink: "https://storage.example.com/contract.pdf", recipients: expect.any(String), - fields: expect.any(String) + fields: expect.any(String), }) ); }); - it('should prepare document for review with deliverable ID', async () => { + it("should prepare document for review with deliverable ID", async () => { const mockResponse = { - data: { - documentId: 'doc-789', - status: 'review_ready' - } + success: true, + documentId: "doc-789", + status: "review_ready", + message: "Document prepared for review", }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForReview({ - deliverableId: 'deliverable-abc', + const result = await TurboSign.createSignatureReviewLink({ + deliverableId: "deliverable-abc", recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-789'); + expect(result.documentId).toBe("doc-789"); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/single/prepare-for-review', + "/turbosign/single/prepare-for-review", expect.objectContaining({ - deliverableId: 'deliverable-abc' + deliverableId: "deliverable-abc", }) ); }); - it('should prepare document for review with template ID', async () => { + it("should prepare document for review with template ID", async () => { const mockResponse = { - data: { - documentId: 'doc-template', - status: 'review_ready' - } + success: true, + documentId: "doc-template", + status: "review_ready", + message: "Document prepared for review", }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForReview({ - templateId: 'template-xyz', + const result = await TurboSign.createSignatureReviewLink({ + templateId: "template-xyz", recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-template'); + expect(result.documentId).toBe("doc-template"); }); - it('should include optional fields in request', async () => { - const mockResponse = { data: { documentId: 'doc-123', status: 'review_ready' } }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + it("should include optional fields in request", async () => { + const mockResponse = { + success: true, + documentId: "doc-123", + status: "review_ready", + message: "Document prepared for review", + }; + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - await TurboSign.prepareForReview({ - fileLink: 'https://example.com/doc.pdf', + await TurboSign.createSignatureReviewLink({ + fileLink: "https://example.com/doc.pdf", recipients: mockRecipients, fields: mockFields, - documentName: 'Test Contract', - documentDescription: 'A test contract', - senderName: 'Sales Team', - senderEmail: 'sales@company.com', - ccEmails: ['admin@company.com', 'legal@company.com'] + documentName: "Test Contract", + documentDescription: "A test contract", + senderName: "Sales Team", + senderEmail: "sales@company.com", + ccEmails: ["admin@company.com", "legal@company.com"], }); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/single/prepare-for-review', + "/turbosign/single/prepare-for-review", expect.objectContaining({ - documentName: 'Test Contract', - documentDescription: 'A test contract', - senderName: 'Sales Team', - senderEmail: 'sales@company.com', - ccEmails: expect.any(String) + documentName: "Test Contract", + documentDescription: "A test contract", + senderName: "Sales Team", + senderEmail: "sales@company.com", + ccEmails: expect.any(String), }) ); }); + + it("should support template anchor-based field positioning", async () => { + const mockResponse = { + success: true, + documentId: "doc-anchor", + status: "review_ready", + message: "Document prepared for review", + }; + + const fieldsWithAnchor: N8nField[] = [ + { + type: "signature", + recipientEmail: "john@example.com", + template: { + anchor: "{SignHere}", + placement: "replace", + size: { width: 200, height: 50 }, + }, + }, + ]; + + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); + + const result = await TurboSign.createSignatureReviewLink({ + templateId: "template-with-anchors", + recipients: mockRecipients, + fields: fieldsWithAnchor, + }); + + expect(result.documentId).toBe("doc-anchor"); + }); }); - describe('prepareForSigningSingle', () => { + describe("sendSignature", () => { const mockRecipients: N8nRecipient[] = [ - { name: 'John Doe', email: 'john@example.com', order: 1 } + { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ]; const mockFields: N8nField[] = [ - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 } + { + type: "signature", + page: 1, + x: 100, + y: 500, + width: 200, + height: 50, + recipientEmail: "john@example.com", + }, ]; - it('should prepare document for signing and send emails', async () => { + it("should prepare document for signing and send emails", async () => { const mockResponse = { - data: { - documentId: 'doc-123', - status: 'sent', - recipients: [ - { - id: 'rec-1', - name: 'John Doe', - email: 'john@example.com', - status: 'pending', - signUrl: 'https://sign.example.com/rec-1' - } - ] - } + success: true, + documentId: "doc-123", + message: "Document sent for signing", }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForSigningSingle({ - fileLink: 'https://storage.example.com/contract.pdf', + const result = await TurboSign.sendSignature({ + fileLink: "https://storage.example.com/contract.pdf", recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-123'); - expect(result.status).toBe('sent'); - expect(result.recipients[0].signUrl).toBeDefined(); + expect(result.success).toBe(true); + expect(result.documentId).toBe("doc-123"); + expect(result.message).toContain("signing"); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/single/prepare-for-signing', + "/turbosign/single/prepare-for-signing", expect.any(Object) ); }); - it('should handle file upload for signing', async () => { - const mockFile = Buffer.from('mock-pdf-content'); + it("should handle file upload for signing", async () => { + const mockFile = Buffer.from("mock-pdf-content"); const mockResponse = { - data: { - documentId: 'doc-upload', - status: 'sent' - } + success: true, + documentId: "doc-upload", + message: "Document sent for signing", }; - MockedHttpClient.prototype.uploadFile = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.uploadFile = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.prepareForSigningSingle({ + const result = await TurboSign.sendSignature({ file: mockFile, - fileName: 'contract.pdf', + fileName: "contract.pdf", recipients: mockRecipients, - fields: mockFields + fields: mockFields, }); - expect(result.documentId).toBe('doc-upload'); + expect(result.documentId).toBe("doc-upload"); + }); + + it("should support checkbox fields with default values", async () => { + const mockResponse = { + success: true, + documentId: "doc-checkbox", + message: "Document sent for signing", + }; + + const fieldsWithCheckbox: N8nField[] = [ + { + type: "signature", + page: 1, + x: 100, + y: 500, + width: 200, + height: 50, + recipientEmail: "john@example.com", + }, + { + type: "checkbox", + page: 1, + x: 100, + y: 600, + width: 20, + height: 20, + recipientEmail: "john@example.com", + defaultValue: "true", + }, + ]; + + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); + + const result = await TurboSign.sendSignature({ + fileLink: "https://example.com/doc.pdf", + recipients: mockRecipients, + fields: fieldsWithCheckbox, + }); + + expect(result.documentId).toBe("doc-checkbox"); }); }); - describe('getStatus', () => { - it('should get document status', async () => { + describe("getStatus", () => { + it("should get document status", async () => { const mockResponse = { data: { - documentId: 'doc-123', - status: 'pending', - name: 'Test Document', + documentId: "doc-123", + status: "under_review", + name: "Test Document", recipients: [ - { id: 'rec-1', name: 'John Doe', email: 'john@example.com', status: 'pending' } + { + id: "rec-1", + name: "John Doe", + email: "john@example.com", + status: "pending", + }, ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z' - } + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, }; - MockedHttpClient.prototype.get = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.get = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.getStatus('doc-123'); + const result = await TurboSign.getStatus("doc-123"); - expect(result.documentId).toBe('doc-123'); - expect(result.status).toBe('pending'); + expect(result.documentId).toBe("doc-123"); + expect(result.status).toBe("under_review"); expect(MockedHttpClient.prototype.get).toHaveBeenCalledWith( - '/turbosign/documents/doc-123/status' + "/turbosign/documents/doc-123/status" ); }); }); - describe('download', () => { - it('should download signed document as Blob', async () => { - const mockPdfContent = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF - const mockBlob = new Blob([mockPdfContent], { type: 'application/pdf' }); + describe("download", () => { + it("should download signed document as Blob", async () => { + const mockPresignedResponse = { + downloadUrl: "https://s3.example.com/presigned-url", + fileName: "signed-document.pdf", + }; + + const mockPdfContent = new ArrayBuffer(4); + const mockFetchResponse = { + ok: true, + arrayBuffer: jest.fn().mockResolvedValue(mockPdfContent), + }; - MockedHttpClient.prototype.get = jest.fn().mockResolvedValue(mockBlob); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.get = jest + .fn() + .mockResolvedValue(mockPresignedResponse); + mockFetch.mockResolvedValue(mockFetchResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.download('doc-123'); + const result = await TurboSign.download("doc-123"); expect(result).toBeInstanceOf(Blob); expect(MockedHttpClient.prototype.get).toHaveBeenCalledWith( - '/turbosign/documents/doc-123/download' + "/turbosign/documents/doc-123/download" + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://s3.example.com/presigned-url" + ); + }); + + it("should throw error if S3 download fails", async () => { + const mockPresignedResponse = { + downloadUrl: "https://s3.example.com/presigned-url", + fileName: "signed-document.pdf", + }; + + const mockFetchResponse = { + ok: false, + statusText: "Forbidden", + }; + + MockedHttpClient.prototype.get = jest + .fn() + .mockResolvedValue(mockPresignedResponse); + mockFetch.mockResolvedValue(mockFetchResponse); + TurboSign.configure({ apiKey: "test-key" }); + + await expect(TurboSign.download("doc-123")).rejects.toThrow( + "Failed to download file" ); }); }); - describe('void', () => { - it('should void a document with reason', async () => { + describe("void", () => { + it("should void a document with reason", async () => { const mockResponse = { data: { - documentId: 'doc-123', - status: 'voided', - voidedAt: '2024-01-01T12:00:00Z' - } + documentId: "doc-123", + status: "voided", + voidedAt: "2024-01-01T12:00:00Z", + }, }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.void('doc-123', 'Document needs revision'); + const result = await TurboSign.void("doc-123", "Document needs revision"); - expect(result.documentId).toBe('doc-123'); - expect(result.status).toBe('voided'); + expect(result.documentId).toBe("doc-123"); + expect(result.status).toBe("voided"); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/documents/doc-123/void', - { reason: 'Document needs revision' } + "/turbosign/documents/doc-123/void", + { reason: "Document needs revision" } ); }); }); - describe('resend', () => { - it('should resend email to specific recipients', async () => { + describe("resend", () => { + it("should resend email to specific recipients", async () => { + const mockResponse = { + data: { + documentId: "doc-123", + message: "Emails resent successfully", + resentAt: "2024-01-01T12:00:00Z", + }, + }; + + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); + + const result = await TurboSign.resend("doc-123", ["rec-1", "rec-2"]); + + expect(result.message).toContain("resent"); + expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( + "/turbosign/documents/doc-123/resend-email", + { recipientIds: ["rec-1", "rec-2"] } + ); + }); + + it("should resend email to all recipients when empty array", async () => { const mockResponse = { data: { - documentId: 'doc-123', - message: 'Emails resent successfully', - resentAt: '2024-01-01T12:00:00Z' - } + documentId: "doc-123", + message: "Emails resent to all recipients", + resentAt: "2024-01-01T12:00:00Z", + }, }; - MockedHttpClient.prototype.post = jest.fn().mockResolvedValue(mockResponse); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); - const result = await TurboSign.resend('doc-123', ['rec-1', 'rec-2']); + const result = await TurboSign.resend("doc-123", []); - expect(result.message).toContain('resent'); + expect(result.message).toContain("resent"); expect(MockedHttpClient.prototype.post).toHaveBeenCalledWith( - '/turbosign/documents/doc-123/resend-email', - { recipientIds: ['rec-1', 'rec-2'] } + "/turbosign/documents/doc-123/resend-email", + { recipientIds: [] } ); }); }); - describe('Error Handling', () => { - it('should throw error when API key is not configured', async () => { - // Don't configure, let it auto-initialize without API key - MockedHttpClient.prototype.get = jest.fn().mockRejectedValue( - new Error('API key is required') + describe("getAuditTrail", () => { + it("should get audit trail for a document", async () => { + const mockResponse = { + data: { + documentId: "doc-123", + entries: [ + { + event: "document_created", + actor: "sender@example.com", + timestamp: "2024-01-01T10:00:00Z", + ipAddress: "192.168.1.1", + }, + { + event: "email_sent", + actor: "system", + timestamp: "2024-01-01T10:01:00Z", + details: { recipientEmail: "john@example.com" }, + }, + { + event: "document_viewed", + actor: "john@example.com", + timestamp: "2024-01-01T11:00:00Z", + ipAddress: "10.0.0.1", + }, + { + event: "document_signed", + actor: "john@example.com", + timestamp: "2024-01-01T11:05:00Z", + ipAddress: "10.0.0.1", + }, + ], + }, + }; + + MockedHttpClient.prototype.get = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); + + const result = await TurboSign.getAuditTrail("doc-123"); + + expect(result.documentId).toBe("doc-123"); + expect(result.entries).toHaveLength(4); + expect(result.entries[0].event).toBe("document_created"); + expect(result.entries[3].event).toBe("document_signed"); + expect(MockedHttpClient.prototype.get).toHaveBeenCalledWith( + "/turbosign/documents/doc-123/audit-trail" ); + }); + + it("should return empty entries for new document", async () => { + const mockResponse = { + data: { + documentId: "doc-new", + entries: [], + }, + }; + + MockedHttpClient.prototype.get = jest + .fn() + .mockResolvedValue(mockResponse); + TurboSign.configure({ apiKey: "test-key" }); + + const result = await TurboSign.getAuditTrail("doc-new"); + + expect(result.documentId).toBe("doc-new"); + expect(result.entries).toHaveLength(0); + }); + }); + + describe("Error Handling", () => { + it("should throw error when API key is not configured", async () => { + // Don't configure, let it auto-initialize without API key + MockedHttpClient.prototype.get = jest + .fn() + .mockRejectedValue(new Error("API key is required")); - await expect(TurboSign.getStatus('doc-123')).rejects.toThrow(); + await expect(TurboSign.getStatus("doc-123")).rejects.toThrow(); }); - it('should handle API errors gracefully', async () => { + it("should handle API errors gracefully", async () => { const apiError = { statusCode: 404, - message: 'Document not found', - code: 'DOCUMENT_NOT_FOUND' + message: "Document not found", + code: "DOCUMENT_NOT_FOUND", }; MockedHttpClient.prototype.get = jest.fn().mockRejectedValue(apiError); - TurboSign.configure({ apiKey: 'test-key' }); + TurboSign.configure({ apiKey: "test-key" }); - await expect(TurboSign.getStatus('invalid-doc')).rejects.toEqual(apiError); + await expect(TurboSign.getStatus("invalid-doc")).rejects.toEqual( + apiError + ); }); - it('should handle validation errors', async () => { + it("should handle validation errors", async () => { const validationError = { statusCode: 400, - message: 'Validation failed', + message: "Validation failed", errors: [ - { path: ['recipients', 0, 'email'], message: 'Invalid email format' } - ] + { path: ["recipients", 0, "email"], message: "Invalid email format" }, + ], }; - MockedHttpClient.prototype.post = jest.fn().mockRejectedValue(validationError); - TurboSign.configure({ apiKey: 'test-key' }); + MockedHttpClient.prototype.post = jest + .fn() + .mockRejectedValue(validationError); + TurboSign.configure({ apiKey: "test-key" }); await expect( - TurboSign.prepareForSigningSingle({ - fileLink: 'https://example.com/doc.pdf', - recipients: [{ name: 'Test', email: 'invalid-email', order: 1 }], - fields: [] + TurboSign.sendSignature({ + fileLink: "https://example.com/doc.pdf", + recipients: [ + { name: "Test", email: "invalid-email", signingOrder: 1 }, + ], + fields: [], }) ).rejects.toEqual(validationError); }); + + it("should handle rate limit errors", async () => { + const rateLimitError = { + statusCode: 429, + message: "Rate limit exceeded", + code: "RATE_LIMIT_EXCEEDED", + }; + + MockedHttpClient.prototype.post = jest + .fn() + .mockRejectedValue(rateLimitError); + TurboSign.configure({ apiKey: "test-key" }); + + await expect( + TurboSign.createSignatureReviewLink({ + fileLink: "https://example.com/doc.pdf", + recipients: [ + { name: "Test", email: "test@example.com", signingOrder: 1 }, + ], + fields: [], + }) + ).rejects.toEqual(rateLimitError); + }); }); }); diff --git a/packages/js-sdk/tsconfig.json b/packages/js-sdk/tsconfig.json index 44c26cf4..e2511bdc 100644 --- a/packages/js-sdk/tsconfig.json +++ b/packages/js-sdk/tsconfig.json @@ -5,14 +5,15 @@ "lib": ["ES2020"], "declaration": true, "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node", "jest"] }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/py-sdk/README.md b/packages/py-sdk/README.md index 1d9a524f..be9a8bf3 100644 --- a/packages/py-sdk/README.md +++ b/packages/py-sdk/README.md @@ -322,6 +322,50 @@ def send_contract(request): --- +## Local Testing + +The SDK includes a comprehensive manual test script to verify all functionality locally. + +### Running Manual Tests + +```bash +# Install dependencies +pip install -e . + +# Run the manual test script +python manual_test.py +``` + +### What It Tests + +The `manual_test.py` file tests all SDK methods: +- ✅ `prepare_for_review()` - Document upload for review +- ✅ `prepare_for_signing_single()` - Send for signature +- ✅ `get_status()` - Check document status +- ✅ `download()` - Download signed document +- ✅ `void()` - Cancel signature request +- ✅ `resend()` - Resend signature emails + +### Configuration + +Before running, update the hardcoded values in `manual_test.py`: +- `API_KEY` - Your TurboDocx API key +- `BASE_URL` - API endpoint (default: `http://localhost:3000`) +- `ORG_ID` - Your organization UUID +- `TEST_FILE_PATH` - Path to a test PDF/DOCX file +- `TEST_EMAIL` - Email address for testing + +### Expected Output + +The script will: +1. Upload a test document +2. Send it for signature +3. Check the status +4. Test void and resend operations +5. Print results for each operation + +--- + ## Error Handling ```python diff --git a/packages/py-sdk/manual_test.py b/packages/py-sdk/manual_test.py new file mode 100644 index 00000000..d0f14c98 --- /dev/null +++ b/packages/py-sdk/manual_test.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +TurboSign Python SDK - Manual Test Suite + +Run: python manual_test.py + +Make sure to configure the values below before running. +""" + +import asyncio +import json +import os +import sys + +from turbodocx_sdk import TurboSign + +# ============================================= +# CONFIGURE THESE VALUES BEFORE RUNNING +# ============================================= +API_KEY = "TDX-your-api-key-here" # Replace with your actual TurboDocx API key +BASE_URL = "http://localhost:3000" # Replace with your API URL +ORG_ID = "your-organization-uuid-here" # Replace with your organization UUID + +TEST_PDF_PATH = "/path/to/your/test-document.pdf" # Replace with path to your test PDF/DOCX +TEST_EMAIL = "test-recipient@example.com" # Replace with a real email to receive notifications + +# Configure TurboSign +TurboSign.configure(api_key=API_KEY, base_url=BASE_URL, org_id=ORG_ID) + + +# ============================================= +# TEST FUNCTIONS +# ============================================= + +async def test_create_signature_review_link(): + """Test 1: Prepare document for review (no emails sent) - using fileLink""" + print("\n--- Test 1: create_signature_review_link (using fileLink) ---") + + # Using fileLink instead of file upload or templateId + # Replace with a publicly accessible PDF/DOCX URL + file_url = "https://example.com/sample-document.pdf" # Replace with actual publicly accessible PDF URL + + result = await TurboSign.create_signature_review_link( + file_link=file_url, + recipients=[ + {"name": "Signer One", "email": TEST_EMAIL, "signingOrder": 1} + ], + fields=[ + { + "recipientEmail": TEST_EMAIL, + "type": "signature", + "page": 1, + "x": 100, + "y": 550, + "width": 200, + "height": 50, + }, + { + "recipientEmail": TEST_EMAIL, + "type": "checkbox", + "page": 1, + "x": 320, + "y": 550, + "width": 50, + "height": 50, + "defaultValue": "true", + }, + ], + document_name="Review Test Document (fileLink)", + ) + + print("Result:", json.dumps(result, indent=2)) + return result.get("documentId") + + +async def test_send_signature(): + """Test 2: Prepare document for signing and send emails""" + print("\n--- Test 2: send_signature ---") + + with open(TEST_PDF_PATH, "rb") as f: + pdf_buffer = f.read() + + result = await TurboSign.send_signature( + # template_id="341af877-02d4-4549-823b-87089a3f7b02", # Replace with your template ID + file=pdf_buffer, + recipients=[ + {"name": "Test User", "email": TEST_EMAIL, "signingOrder": 1} + ], + fields=[ + { + "recipientEmail": TEST_EMAIL, + "type": "text", + "template": { + "anchor": "{hello}", + "placement": "replace", + "size": {"width": 200, "height": 80}, + "offset": {"x": 0, "y": 0}, + "caseSensitive": True, + "useRegex": False, + }, + "defaultValue": "Sample Text", + "required": True, + "isMultiline": True, + }, + { + "recipientEmail": TEST_EMAIL, + "type": "last_name", + "page": 1, + "x": 100, + "y": 650, + "width": 200, + "height": 50, + "defaultValue": "Doe", + }, + ], + document_name="Signing Test Document", + document_description="Sample contract for testing single-step signature endpoint", + sender_name="Test Sender", + sender_email="sender@example.com", + cc_emails=["cc@example.com"], + ) + + print("Result:", json.dumps(result, indent=2)) + return result.get("documentId") + + +async def test_get_status(document_id: str): + """Test 3: Get document status""" + print("\n--- Test 3: get_status ---") + + result = await TurboSign.get_status(document_id) + print("Result:", json.dumps(result, indent=2)) + return result + + +async def test_download(document_id: str): + """Test 4: Download signed document""" + print("\n--- Test 4: download ---") + + result = await TurboSign.download(document_id) + print(f"Result: PDF received, size: {len(result)} bytes") + + # Save to file + output_path = "./downloaded-document.pdf" + with open(output_path, "wb") as f: + f.write(result) + print(f"File saved to: {output_path}") + + return result + + +async def test_resend(document_id: str, recipient_ids: list): + """Test 5: Resend signature emails""" + print("\n--- Test 5: resend_email ---") + + result = await TurboSign.resend_email(document_id, recipient_ids) + print("Result:", json.dumps(result, indent=2)) + return result + + +async def test_void(document_id: str): + """Test 6: Void document""" + print("\n--- Test 6: void_document ---") + + result = await TurboSign.void_document(document_id, "Testing void functionality") + print("Result:", json.dumps(result, indent=2)) + return result + + +async def test_get_audit_trail(document_id: str): + """Test 7: Get audit trail""" + print("\n--- Test 7: get_audit_trail ---") + + result = await TurboSign.get_audit_trail(document_id) + print("Result:", json.dumps(result, indent=2)) + return result + + +# ============================================= +# MAIN TEST RUNNER +# ============================================= + +async def run_all_tests(): + print("==============================================") + print("TurboSign Python SDK - Manual Test Suite") + print("==============================================") + + # Check if test PDF exists + if not os.path.exists(TEST_PDF_PATH): + print(f"\nError: Test PDF not found at {TEST_PDF_PATH}") + print("Please add a test PDF file and update TEST_PDF_PATH.") + sys.exit(1) + + try: + # Uncomment and run tests as needed: + + # Test 1: Prepare for Review + # review_doc_id = await test_create_signature_review_link() + + # Test 2: Prepare for Signing (creates a new document) + # sign_doc_id = await test_send_signature() + + # Test 3: Get Status (replace with actual document ID) + # await test_get_status("document-uuid-here") + + # Test 4: Download (replace with actual document ID) + # await test_download("document-uuid-here") + + # Test 5: Resend (replace with actual document ID and recipient ID) + # await test_resend("document-uuid-here", ["recipient-uuid-here"]) + + # Test 6: Void (do this last as it cancels the document) + # await test_void("document-uuid-here") + + # Test 7: Get Audit Trail (replace with actual document ID) + # await test_get_audit_trail("document-uuid-here") + + print("\n==============================================") + print("All tests completed successfully!") + print("==============================================") + + except Exception as error: + print("\n==============================================") + print("TEST FAILED") + print("==============================================") + print(f"Error: {error}") + if hasattr(error, "status_code"): + print(f"Status Code: {error.status_code}") + if hasattr(error, "code"): + print(f"Error Code: {error.code}") + sys.exit(1) + + +# Run tests +if __name__ == "__main__": + asyncio.run(run_all_tests()) diff --git a/packages/py-sdk/pytest.ini b/packages/py-sdk/pytest.ini new file mode 100644 index 00000000..1224c6a6 --- /dev/null +++ b/packages/py-sdk/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +python_classes = Test* +addopts = -v --tb=short +asyncio_mode = auto diff --git a/packages/py-sdk/src/turbodocx_sdk/__init__.py b/packages/py-sdk/src/turbodocx_sdk/__init__.py index 8766090e..e23629e4 100644 --- a/packages/py-sdk/src/turbodocx_sdk/__init__.py +++ b/packages/py-sdk/src/turbodocx_sdk/__init__.py @@ -10,25 +10,40 @@ from typing import Optional from .modules.sign import TurboSign -from .http import HttpClient, TurboDocxError, AuthenticationError, NetworkError +from .http import ( + HttpClient, + TurboDocxError, + AuthenticationError, + ValidationError, + NotFoundError, + RateLimitError, + NetworkError +) class TurboDocxClient: """Main client for interacting with TurboDocx API""" - def __init__(self, api_key: str, base_url: str = "https://api.turbodocx.com"): + def __init__( + self, + api_key: str, + org_id: str, + base_url: str = "https://api.turbodocx.com" + ): """ Initialize TurboDocx client Args: api_key: Your TurboDocx API key + org_id: Your Organization ID (required for authentication) base_url: Base URL for the API (default: https://api.turbodocx.com) """ self.api_key = api_key + self.org_id = org_id self.base_url = base_url # Configure TurboSign module - TurboSign.configure(api_key=api_key, base_url=base_url) + TurboSign.configure(api_key=api_key, org_id=org_id, base_url=base_url) @property def sign(self) -> type: @@ -42,6 +57,9 @@ def sign(self) -> type: "HttpClient", "TurboDocxError", "AuthenticationError", + "ValidationError", + "NotFoundError", + "RateLimitError", "NetworkError", "__version__", ] diff --git a/packages/py-sdk/src/turbodocx_sdk/http.py b/packages/py-sdk/src/turbodocx_sdk/http.py index 5f54d93b..824fb29f 100644 --- a/packages/py-sdk/src/turbodocx_sdk/http.py +++ b/packages/py-sdk/src/turbodocx_sdk/http.py @@ -3,11 +3,58 @@ """ import os -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple, Union import httpx +def detect_file_type(file_bytes: bytes) -> Tuple[str, str]: + """ + Detect file type from magic bytes. + + Args: + file_bytes: File content as bytes + + Returns: + Tuple of (mimetype, extension) + """ + if len(file_bytes) < 4: + return ("application/octet-stream", "bin") + + # PDF: %PDF (0x25 0x50 0x44 0x46) + if file_bytes[0:4] == b'%PDF': + return ("application/pdf", "pdf") + + # ZIP-based formats (DOCX, PPTX): starts with PK (0x50 0x4B) + if file_bytes[0:2] == b'PK': + # Check first 2000 bytes for internal markers + header = file_bytes[:min(len(file_bytes), 2000)] + header_str = header.decode('utf-8', errors='ignore') + + # PPTX contains 'ppt/' in the ZIP structure + if 'ppt/' in header_str: + return ( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pptx" + ) + + # DOCX contains 'word/' in the ZIP structure + if 'word/' in header_str: + return ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx" + ) + + # Default to DOCX for unknown ZIP + return ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx" + ) + + # Unknown file type + return ("application/octet-stream", "bin") + + class TurboDocxError(Exception): """Base exception for TurboDocx API errors""" @@ -18,7 +65,22 @@ def __init__(self, message: str, status_code: Optional[int] = None, code: Option class AuthenticationError(TurboDocxError): - """Raised when authentication fails""" + """Raised when authentication fails (HTTP 401)""" + pass + + +class ValidationError(TurboDocxError): + """Raised when validation fails (HTTP 400)""" + pass + + +class NotFoundError(TurboDocxError): + """Raised when resource is not found (HTTP 404)""" + pass + + +class RateLimitError(TurboDocxError): + """Raised when rate limit is exceeded (HTTP 429)""" pass @@ -34,7 +96,8 @@ def __init__( self, api_key: Optional[str] = None, access_token: Optional[str] = None, - base_url: Optional[str] = None + base_url: Optional[str] = None, + org_id: Optional[str] = None ): """ Initialize HTTP client @@ -43,14 +106,19 @@ def __init__( api_key: TurboDocx API key access_token: OAuth2 access token (alternative to API key) base_url: Base URL for the API + org_id: Organization ID (required for authentication) """ self.api_key = api_key or os.environ.get("TURBODOCX_API_KEY") self.access_token = access_token self.base_url = base_url or os.environ.get("TURBODOCX_BASE_URL", "https://api.turbodocx.com") + self.org_id = org_id or os.environ.get("TURBODOCX_ORG_ID") if not self.api_key and not self.access_token: raise AuthenticationError("API key or access token is required") + if not self.org_id: + raise AuthenticationError("Organization ID (org_id) is required for authentication") + def _get_headers(self, include_content_type: bool = True) -> Dict[str, str]: """Get default headers for requests""" headers: Dict[str, str] = {} @@ -58,13 +126,28 @@ def _get_headers(self, include_content_type: bool = True) -> Dict[str, str]: if include_content_type: headers["Content-Type"] = "application/json" + # API key is sent as Bearer token (backend expects Authorization header) if self.access_token: headers["Authorization"] = f"Bearer {self.access_token}" elif self.api_key: - headers["X-API-Key"] = self.api_key + headers["Authorization"] = f"Bearer {self.api_key}" + + # Organization ID header (required by backend) + if self.org_id: + headers["x-rapiddocx-org-id"] = self.org_id return headers + def _smart_unwrap(self, data: Any) -> Any: + """ + Smart unwrap response data. + If response has ONLY "data" key, extract it. + This handles backend responses that wrap data in { "data": { ... } } + """ + if isinstance(data, dict) and list(data.keys()) == ["data"]: + return data["data"] + return data + async def _handle_error_response(self, response: httpx.Response) -> None: """Handle error response from API""" error_message = f"HTTP {response.status_code}: {response.reason_phrase}" @@ -77,8 +160,14 @@ async def _handle_error_response(self, response: httpx.Response) -> None: except Exception: pass + if response.status_code == 400: + raise ValidationError(error_message, response.status_code, error_code) if response.status_code == 401: - raise AuthenticationError(error_message) + raise AuthenticationError(error_message, response.status_code, error_code) + if response.status_code == 404: + raise NotFoundError(error_message, response.status_code, error_code) + if response.status_code == 429: + raise RateLimitError(error_message, response.status_code, error_code) raise TurboDocxError(error_message, response.status_code, error_code) @@ -95,7 +184,7 @@ async def get(self, path: str) -> Any: url = f"{self.base_url}{path}" headers = self._get_headers() - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=60.0) as client: try: response = await client.get(url, headers=headers) @@ -104,11 +193,17 @@ async def get(self, path: str) -> Any: content_type = response.headers.get("content-type", "") if "application/json" in content_type: - return response.json() + return self._smart_unwrap(response.json()) return response.content - except (httpx.NetworkError, httpx.TimeoutException) as e: - raise NetworkError(f"Network request failed: {e}") + except httpx.TimeoutException as e: + raise NetworkError(f"Request timed out after 60 seconds: {str(e) or 'Timeout'}") + except httpx.NetworkError as e: + raise NetworkError(f"Network request failed: {str(e) or 'Connection error'}") + except TurboDocxError: + raise + except Exception as e: + raise NetworkError(f"Request failed: {str(e) or 'Unknown error'}") async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any: """ @@ -116,7 +211,7 @@ async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any: Args: path: API endpoint path - data: Request body data + data: Request body data (will be sent as JSON) Returns: Response data @@ -124,22 +219,28 @@ async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any: url = f"{self.base_url}{path}" headers = self._get_headers() - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=120.0) as client: try: response = await client.post(url, headers=headers, json=data) if not response.is_success: await self._handle_error_response(response) - return response.json() - except (httpx.NetworkError, httpx.TimeoutException) as e: - raise NetworkError(f"Network request failed: {e}") + return self._smart_unwrap(response.json()) + except httpx.TimeoutException as e: + raise NetworkError(f"Request timed out after 120 seconds: {str(e) or 'Timeout'}") + except httpx.NetworkError as e: + raise NetworkError(f"Network request failed: {str(e) or 'Connection error'}") + except TurboDocxError: + raise + except Exception as e: + raise NetworkError(f"Request failed: {str(e) or 'Unknown error'}") async def upload_file( self, path: str, - file: bytes, - file_name: str = "file", + file: Union[str, bytes], + file_name: Optional[str] = None, field_name: str = "file", additional_data: Optional[Dict[str, Any]] = None ) -> Any: @@ -148,8 +249,8 @@ async def upload_file( Args: path: API endpoint path - file: File content as bytes - file_name: Name of the file + file: File path (str) or file content (bytes) + file_name: Name of the file (auto-detected for file paths) field_name: Form field name for file additional_data: Additional form data @@ -159,16 +260,36 @@ async def upload_file( url = f"{self.base_url}{path}" headers = self._get_headers(include_content_type=False) - files = {field_name: (file_name, file, "application/pdf")} + # Handle file path vs bytes + if isinstance(file, str): + # File path - read from disk + with open(file, 'rb') as f: + file_bytes = f.read() + if file_name is None: + file_name = os.path.basename(file) + else: + # Bytes - use directly + file_bytes = file + if file_name is None: + # Detect extension from content + _, ext = detect_file_type(file_bytes) + file_name = f"document.{ext}" + + # Detect MIME type from content + mime_type, _ = detect_file_type(file_bytes) + + files = {field_name: (file_name, file_bytes, mime_type)} data = additional_data or {} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=60.0) as client: try: response = await client.post(url, headers=headers, files=files, data=data) if not response.is_success: await self._handle_error_response(response) - return response.json() + return self._smart_unwrap(response.json()) except (httpx.NetworkError, httpx.TimeoutException) as e: - raise NetworkError(f"File upload failed: {e}") + raise NetworkError(f"File upload failed: {str(e) or 'Connection error'}") + except Exception as e: + raise NetworkError(f"File upload failed: {str(e) or 'Unknown error'}") diff --git a/packages/py-sdk/src/turbodocx_sdk/modules/sign.py b/packages/py-sdk/src/turbodocx_sdk/modules/sign.py index 36fed39e..94567044 100644 --- a/packages/py-sdk/src/turbodocx_sdk/modules/sign.py +++ b/packages/py-sdk/src/turbodocx_sdk/modules/sign.py @@ -1,19 +1,22 @@ """ TurboSign Module - Digital signature operations -Provides 100% parity with n8n-nodes-turbodocx operations: -- prepare_for_review -- prepare_for_signing_single +Provides single-step signature operations: +- create_signature_review_link +- send_signature - get_status - download - void_document - resend_email +- get_audit_trail """ import json from typing import Any, Dict, List, Optional, Union -from ..http import HttpClient +import httpx + +from ..http import HttpClient, NetworkError class TurboSign: @@ -26,7 +29,8 @@ def configure( cls, api_key: Optional[str] = None, access_token: Optional[str] = None, - base_url: str = "https://api.turbodocx.com" + base_url: str = "https://api.turbodocx.com", + org_id: Optional[str] = None ) -> None: """ Configure the TurboSign module with API credentials @@ -35,11 +39,13 @@ def configure( api_key: TurboDocx API key access_token: OAuth2 access token (alternative to API key) base_url: Base URL for the API (default: https://api.turbodocx.com) + org_id: Organization ID (required for authentication) """ cls._client = HttpClient( api_key=api_key, access_token=access_token, - base_url=base_url + base_url=base_url, + org_id=org_id ) @classmethod @@ -47,16 +53,12 @@ def _get_client(cls) -> HttpClient: """Get the HTTP client instance, raising error if not configured""" if cls._client is None: raise RuntimeError( - "TurboSign not configured. Call TurboSign.configure(api_key='...') first." + "TurboSign not configured. Call TurboSign.configure(api_key='...', org_id='...') first." ) return cls._client - # ============================================ - # N8N PARITY METHODS (single-call operations) - # ============================================ - @classmethod - async def prepare_for_review( + async def create_signature_review_link( cls, recipients: List[Dict[str, Any]], fields: List[Dict[str, Any]], @@ -73,7 +75,7 @@ async def prepare_for_review( cc_emails: Optional[List[str]] = None ) -> Dict[str, Any]: """ - Prepare document for review without sending emails + Create signature review link without sending emails This method uploads a document with signature fields and recipients, but does NOT send signature request emails. Use this to preview @@ -81,7 +83,9 @@ async def prepare_for_review( Args: recipients: List of recipients who will sign + Each recipient should have: name, email, signingOrder fields: Signature fields configuration + Each field should have: type, recipientEmail, and positioning info file: PDF file content as bytes file_name: Original filename file_link: URL to document file @@ -94,61 +98,78 @@ async def prepare_for_review( cc_emails: List of CC email addresses Returns: - Document ready for review with preview URL + Response with documentId, status, previewUrl, and recipients Example: - >>> result = await TurboSign.prepare_for_review( + >>> result = await TurboSign.create_signature_review_link( ... file=pdf_bytes, - ... recipients=[{"name": "John Doe", "email": "john@example.com", "order": 1}], - ... fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientOrder": 1}] + ... recipients=[{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}], + ... fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "john@example.com"}] ... ) """ client = cls._get_client() - # Serialize recipients and fields to JSON strings (as n8n node does) - form_data: Dict[str, Any] = { - "recipients": json.dumps(recipients), - "fields": json.dumps(fields), - } - - # Add optional fields - if document_name: - form_data["documentName"] = document_name - if document_description: - form_data["documentDescription"] = document_description - if sender_name: - form_data["senderName"] = sender_name - if sender_email: - form_data["senderEmail"] = sender_email - if cc_emails: - form_data["ccEmails"] = ",".join(cc_emails) - # Handle different file input methods if file: - response = await client.upload_file( + # For file upload, use form data with JSON strings + form_data: Dict[str, Any] = { + "recipients": json.dumps(recipients), + "fields": json.dumps(fields), + } + + # Add optional fields + if document_name: + form_data["documentName"] = document_name + if document_description: + form_data["documentDescription"] = document_description + if sender_name: + form_data["senderName"] = sender_name + if sender_email: + form_data["senderEmail"] = sender_email + if cc_emails: + form_data["ccEmails"] = json.dumps(cc_emails) + + return await client.upload_file( "/turbosign/single/prepare-for-review", file=file, file_name=file_name or "document.pdf", additional_data=form_data ) else: + # For JSON body (template_id, file_link, deliverable_id) + # Backend expects recipients/fields as JSON strings (same as form-data) + json_body: Dict[str, Any] = { + "recipients": json.dumps(recipients), + "fields": json.dumps(fields), + } + + # Add optional fields + if document_name: + json_body["documentName"] = document_name + if document_description: + json_body["documentDescription"] = document_description + if sender_name: + json_body["senderName"] = sender_name + if sender_email: + json_body["senderEmail"] = sender_email + if cc_emails: + json_body["ccEmails"] = json.dumps(cc_emails) + # URL, deliverable, or template if file_link: - form_data["fileLink"] = file_link + json_body["fileLink"] = file_link if deliverable_id: - form_data["deliverableId"] = deliverable_id + json_body["deliverableId"] = deliverable_id if template_id: - form_data["templateId"] = template_id + json_body["templateId"] = template_id - response = await client.post( + return await client.post( "/turbosign/single/prepare-for-review", - data=form_data + data=json_body ) - return response.get("data", response) - @classmethod - async def prepare_for_signing_single( + async def send_signature( cls, recipients: List[Dict[str, Any]], fields: List[Dict[str, Any]], @@ -165,15 +186,16 @@ async def prepare_for_signing_single( cc_emails: Optional[List[str]] = None ) -> Dict[str, Any]: """ - Prepare document for signing and send emails in a single call + Send signature request and immediately send emails This method uploads a document with signature fields and recipients, then immediately sends signature request emails to all recipients. - This is the n8n-equivalent "Prepare for Signing" operation. Args: recipients: List of recipients who will sign + Each recipient should have: name, email, signingOrder fields: Signature fields configuration + Each field should have: type, recipientEmail, and positioning info file: PDF file content as bytes file_name: Original filename file_link: URL to document file @@ -186,60 +208,76 @@ async def prepare_for_signing_single( cc_emails: List of CC email addresses Returns: - Document with sign URLs for each recipient + Response with success, documentId, and message Example: - >>> result = await TurboSign.prepare_for_signing_single( + >>> result = await TurboSign.send_signature( ... file=pdf_bytes, - ... recipients=[{"name": "John Doe", "email": "john@example.com", "order": 1}], - ... fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientOrder": 1}] + ... recipients=[{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}], + ... fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "john@example.com"}] ... ) - >>> print(result["recipients"][0]["signUrl"]) """ client = cls._get_client() - # Serialize recipients and fields to JSON strings (as n8n node does) - form_data: Dict[str, Any] = { - "recipients": json.dumps(recipients), - "fields": json.dumps(fields), - } - - # Add optional fields - if document_name: - form_data["documentName"] = document_name - if document_description: - form_data["documentDescription"] = document_description - if sender_name: - form_data["senderName"] = sender_name - if sender_email: - form_data["senderEmail"] = sender_email - if cc_emails: - form_data["ccEmails"] = ",".join(cc_emails) - # Handle different file input methods if file: - response = await client.upload_file( + # For file upload, use form data with JSON strings + form_data: Dict[str, Any] = { + "recipients": json.dumps(recipients), + "fields": json.dumps(fields), + } + + # Add optional fields + if document_name: + form_data["documentName"] = document_name + if document_description: + form_data["documentDescription"] = document_description + if sender_name: + form_data["senderName"] = sender_name + if sender_email: + form_data["senderEmail"] = sender_email + if cc_emails: + form_data["ccEmails"] = json.dumps(cc_emails) + + return await client.upload_file( "/turbosign/single/prepare-for-signing", file=file, file_name=file_name or "document.pdf", additional_data=form_data ) else: + # For JSON body (template_id, file_link, deliverable_id) + # Backend expects recipients/fields as JSON strings (same as form-data) + json_body: Dict[str, Any] = { + "recipients": json.dumps(recipients), + "fields": json.dumps(fields), + } + + # Add optional fields + if document_name: + json_body["documentName"] = document_name + if document_description: + json_body["documentDescription"] = document_description + if sender_name: + json_body["senderName"] = sender_name + if sender_email: + json_body["senderEmail"] = sender_email + if cc_emails: + json_body["ccEmails"] = json.dumps(cc_emails) + # URL, deliverable, or template if file_link: - form_data["fileLink"] = file_link + json_body["fileLink"] = file_link if deliverable_id: - form_data["deliverableId"] = deliverable_id + json_body["deliverableId"] = deliverable_id if template_id: - form_data["templateId"] = template_id + json_body["templateId"] = template_id - response = await client.post( + return await client.post( "/turbosign/single/prepare-for-signing", - data=form_data + data=json_body ) - return response.get("data", response) - @classmethod async def get_status(cls, document_id: str) -> Dict[str, Any]: """ @@ -249,21 +287,23 @@ async def get_status(cls, document_id: str) -> Dict[str, Any]: document_id: ID of the document Returns: - Document status and recipient information + Document status with recipients information Example: >>> status = await TurboSign.get_status("doc-123") >>> print(status["status"]) # 'pending', 'completed', etc. """ client = cls._get_client() - response = await client.get(f"/turbosign/documents/{document_id}/status") - return response.get("data", response) + return await client.get(f"/turbosign/documents/{document_id}/status") @classmethod async def download(cls, document_id: str) -> bytes: """ Download the signed document + The backend returns a presigned S3 URL. This method fetches + that URL and then downloads the actual file from S3. + Args: document_id: ID of the document @@ -276,7 +316,24 @@ async def download(cls, document_id: str) -> bytes: ... f.write(pdf_content) """ client = cls._get_client() - return await client.get(f"/turbosign/documents/{document_id}/download") + + # Get presigned URL from API + response = await client.get(f"/turbosign/documents/{document_id}/download") + + # Response contains downloadUrl + download_url = response.get("downloadUrl") + if not download_url: + raise ValueError("No download URL in response") + + # Fetch actual file from S3 + async with httpx.AsyncClient() as http_client: + try: + file_response = await http_client.get(download_url) + if not file_response.is_success: + raise NetworkError(f"Failed to download file: {file_response.status_code}") + return file_response.content + except (httpx.NetworkError, httpx.TimeoutException) as e: + raise NetworkError(f"Failed to download file: {e}") @classmethod async def void_document(cls, document_id: str, reason: str) -> Dict[str, Any]: @@ -288,17 +345,16 @@ async def void_document(cls, document_id: str, reason: str) -> Dict[str, Any]: reason: Reason for voiding the document Returns: - Void confirmation + Void confirmation with documentId, status, and voidedAt Example: >>> result = await TurboSign.void_document("doc-123", "Document needs revision") """ client = cls._get_client() - response = await client.post( + return await client.post( f"/turbosign/documents/{document_id}/void", data={"reason": reason} ) - return response.get("data", response) @classmethod async def resend_email( @@ -314,14 +370,32 @@ async def resend_email( recipient_ids: List of recipient IDs to resend emails to Returns: - Resend confirmation + Resend confirmation with documentId, message, and resentAt Example: >>> result = await TurboSign.resend_email("doc-123", ["rec-1", "rec-2"]) """ client = cls._get_client() - response = await client.post( + return await client.post( f"/turbosign/documents/{document_id}/resend-email", data={"recipientIds": recipient_ids} ) - return response.get("data", response) + + @classmethod + async def get_audit_trail(cls, document_id: str) -> Dict[str, Any]: + """ + Get audit trail for a document + + Args: + document_id: ID of the document + + Returns: + Audit trail with documentId and entries array + + Example: + >>> audit = await TurboSign.get_audit_trail("doc-123") + >>> for entry in audit["entries"]: + ... print(f"{entry['event']} - {entry['actor']} - {entry['timestamp']}") + """ + client = cls._get_client() + return await client.get(f"/turbosign/documents/{document_id}/audit-trail") diff --git a/packages/py-sdk/tests/test_turbosign.py b/packages/py-sdk/tests/test_turbosign.py index 0bbfc327..ee3483fd 100644 --- a/packages/py-sdk/tests/test_turbosign.py +++ b/packages/py-sdk/tests/test_turbosign.py @@ -1,40 +1,48 @@ """ TurboSign Module Tests -Tests for 100% parity with n8n-nodes-turbodocx operations: -- prepare_for_review -- prepare_for_signing_single +Tests for TurboSign operations: +- create_signature_review_link +- send_signature - get_status - download - void_document - resend_email +- get_audit_trail """ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from turbodocx_sdk import TurboSign +from turbodocx_sdk import TurboSign, ValidationError, NotFoundError, AuthenticationError class TestTurboSignConfigure: """Test TurboSign configuration""" - def test_configure_with_api_key(self): - """Should configure the client with API key""" - TurboSign.configure(api_key="test-api-key") + def test_configure_with_api_key_and_org_id(self): + """Should configure the client with API key and org ID""" + TurboSign.configure(api_key="test-api-key", org_id="test-org-id") assert TurboSign._client is not None assert TurboSign._client.api_key == "test-api-key" + assert TurboSign._client.org_id == "test-org-id" def test_configure_with_custom_base_url(self): """Should configure with custom base URL""" TurboSign.configure( api_key="test-api-key", + org_id="test-org-id", base_url="https://custom-api.example.com" ) assert TurboSign._client.base_url == "https://custom-api.example.com" + def test_configure_requires_org_id(self): + """Should raise error when org_id is not provided""" + with pytest.raises(AuthenticationError, match="Organization ID"): + TurboSign.configure(api_key="test-api-key") -class TestPrepareForReview: - """Test prepare_for_review operation""" + +class TestCreateSignatureReviewLink: + """Test create_signature_review_link operation""" @pytest.fixture(autouse=True) def setup(self): @@ -42,7 +50,7 @@ def setup(self): TurboSign._client = None def mock_recipients(self): - return [{"name": "John Doe", "email": "john@example.com", "order": 1}] + return [{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}] def mock_fields(self): return [{ @@ -52,24 +60,24 @@ def mock_fields(self): "y": 500, "width": 200, "height": 50, - "recipientOrder": 1 + "recipientEmail": "john@example.com" }] @pytest.mark.asyncio - async def test_prepare_for_review_with_file_upload(self): + async def test_create_signature_review_link_with_file_upload(self): """Should prepare document for review with file upload""" mock_response = { - "data": { - "documentId": "doc-123", - "status": "review_ready", - "previewUrl": "https://preview.example.com/doc-123", - "recipients": [{ - "id": "rec-1", - "name": "John Doe", - "email": "john@example.com", - "status": "pending" - }] - } + "success": True, + "documentId": "doc-123", + "status": "review_ready", + "previewUrl": "https://preview.example.com/doc-123", + "message": "Document prepared for review", + "recipients": [{ + "id": "rec-1", + "name": "John Doe", + "email": "john@example.com", + "status": "pending" + }] } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -77,26 +85,27 @@ async def test_prepare_for_review_with_file_upload(self): mock_client.upload_file = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_review( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.create_signature_review_link( file=b"mock-pdf-content", recipients=self.mock_recipients(), fields=self.mock_fields() ) + assert result["success"] is True assert result["documentId"] == "doc-123" assert result["status"] == "review_ready" assert result.get("previewUrl") is not None @pytest.mark.asyncio - async def test_prepare_for_review_with_file_url(self): + async def test_create_signature_review_link_with_file_url(self): """Should prepare document for review with file URL""" mock_response = { - "data": { - "documentId": "doc-456", - "status": "review_ready", - "previewUrl": "https://preview.example.com/doc-456" - } + "success": True, + "documentId": "doc-456", + "status": "review_ready", + "previewUrl": "https://preview.example.com/doc-456", + "message": "Document prepared for review" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -104,8 +113,8 @@ async def test_prepare_for_review_with_file_url(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_review( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.create_signature_review_link( file_link="https://storage.example.com/contract.pdf", recipients=self.mock_recipients(), fields=self.mock_fields() @@ -117,13 +126,13 @@ async def test_prepare_for_review_with_file_url(self): assert "fileLink" in call_args[1]["data"] @pytest.mark.asyncio - async def test_prepare_for_review_with_deliverable_id(self): + async def test_create_signature_review_link_with_deliverable_id(self): """Should prepare document for review with deliverable ID""" mock_response = { - "data": { - "documentId": "doc-789", - "status": "review_ready" - } + "success": True, + "documentId": "doc-789", + "status": "review_ready", + "message": "Document prepared for review" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -131,8 +140,8 @@ async def test_prepare_for_review_with_deliverable_id(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_review( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.create_signature_review_link( deliverable_id="deliverable-abc", recipients=self.mock_recipients(), fields=self.mock_fields() @@ -143,13 +152,13 @@ async def test_prepare_for_review_with_deliverable_id(self): assert "deliverableId" in call_args[1]["data"] @pytest.mark.asyncio - async def test_prepare_for_review_with_template_id(self): + async def test_create_signature_review_link_with_template_id(self): """Should prepare document for review with template ID""" mock_response = { - "data": { - "documentId": "doc-template", - "status": "review_ready" - } + "success": True, + "documentId": "doc-template", + "status": "review_ready", + "message": "Document prepared for review" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -157,8 +166,8 @@ async def test_prepare_for_review_with_template_id(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_review( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.create_signature_review_link( template_id="template-xyz", recipients=self.mock_recipients(), fields=self.mock_fields() @@ -167,17 +176,22 @@ async def test_prepare_for_review_with_template_id(self): assert result["documentId"] == "doc-template" @pytest.mark.asyncio - async def test_prepare_for_review_with_optional_fields(self): + async def test_create_signature_review_link_with_optional_fields(self): """Should include optional fields in request""" - mock_response = {"data": {"documentId": "doc-123", "status": "review_ready"}} + mock_response = { + "success": True, + "documentId": "doc-123", + "status": "review_ready", + "message": "Document prepared for review" + } with patch.object(TurboSign, '_get_client') as mock_get_client: mock_client = MagicMock() mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - await TurboSign.prepare_for_review( + TurboSign.configure(api_key="test-key", org_id="test-org") + await TurboSign.create_signature_review_link( file_link="https://example.com/doc.pdf", recipients=self.mock_recipients(), fields=self.mock_fields(), @@ -196,8 +210,8 @@ async def test_prepare_for_review_with_optional_fields(self): assert data.get("senderEmail") == "sales@company.com" -class TestPrepareForSigningSingle: - """Test prepare_for_signing_single operation""" +class TestSendSignature: + """Test send_signature operation""" @pytest.fixture(autouse=True) def setup(self): @@ -205,7 +219,7 @@ def setup(self): TurboSign._client = None def mock_recipients(self): - return [{"name": "John Doe", "email": "john@example.com", "order": 1}] + return [{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}] def mock_fields(self): return [{ @@ -215,24 +229,16 @@ def mock_fields(self): "y": 500, "width": 200, "height": 50, - "recipientOrder": 1 + "recipientEmail": "john@example.com" }] @pytest.mark.asyncio - async def test_prepare_for_signing_and_send_emails(self): + async def test_send_signature_with_emails(self): """Should prepare document for signing and send emails""" mock_response = { - "data": { - "documentId": "doc-123", - "status": "sent", - "recipients": [{ - "id": "rec-1", - "name": "John Doe", - "email": "john@example.com", - "status": "pending", - "signUrl": "https://sign.example.com/rec-1" - }] - } + "success": True, + "documentId": "doc-123", + "message": "Document sent for signing" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -240,16 +246,15 @@ async def test_prepare_for_signing_and_send_emails(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_signing_single( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.send_signature( file_link="https://storage.example.com/contract.pdf", recipients=self.mock_recipients(), fields=self.mock_fields() ) + assert result["success"] is True assert result["documentId"] == "doc-123" - assert result["status"] == "sent" - assert result["recipients"][0].get("signUrl") is not None mock_client.post.assert_called_once() call_args = mock_client.post.call_args assert "/turbosign/single/prepare-for-signing" in call_args[0][0] @@ -258,10 +263,9 @@ async def test_prepare_for_signing_and_send_emails(self): async def test_handle_file_upload_for_signing(self): """Should handle file upload for signing""" mock_response = { - "data": { - "documentId": "doc-upload", - "status": "sent" - } + "success": True, + "documentId": "doc-upload", + "message": "Document sent for signing" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -269,8 +273,8 @@ async def test_handle_file_upload_for_signing(self): mock_client.upload_file = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.prepare_for_signing_single( + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.send_signature( file=b"mock-pdf-content", file_name="contract.pdf", recipients=self.mock_recipients(), @@ -291,19 +295,17 @@ def setup(self): async def test_get_document_status(self): """Should get document status""" mock_response = { - "data": { - "documentId": "doc-123", - "status": "pending", - "name": "Test Document", - "recipients": [{ - "id": "rec-1", - "name": "John Doe", - "email": "john@example.com", - "status": "pending" - }], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } + "documentId": "doc-123", + "status": "pending", + "name": "Test Document", + "recipients": [{ + "id": "rec-1", + "name": "John Doe", + "email": "john@example.com", + "status": "pending" + }], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -311,7 +313,7 @@ async def test_get_document_status(self): mock_client.get = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") + TurboSign.configure(api_key="test-key", org_id="test-org") result = await TurboSign.get_status("doc-123") assert result["documentId"] == "doc-123" @@ -328,19 +330,32 @@ def setup(self): @pytest.mark.asyncio async def test_download_signed_document(self): - """Should download signed document""" + """Should download signed document via presigned URL""" + mock_api_response = { + "downloadUrl": "https://s3.amazonaws.com/bucket/signed-doc.pdf?presigned=token", + "fileName": "signed-document.pdf" + } mock_pdf_content = b"%PDF-mock-content" with patch.object(TurboSign, '_get_client') as mock_get_client: mock_client = MagicMock() - mock_client.get = AsyncMock(return_value=mock_pdf_content) + mock_client.get = AsyncMock(return_value=mock_api_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - result = await TurboSign.download("doc-123") + with patch('httpx.AsyncClient') as mock_httpx: + mock_http_client = AsyncMock() + mock_response = MagicMock() + mock_response.is_success = True + mock_response.content = mock_pdf_content + mock_http_client.get = AsyncMock(return_value=mock_response) + mock_httpx.return_value.__aenter__.return_value = mock_http_client - assert result == mock_pdf_content - mock_client.get.assert_called_once_with("/turbosign/documents/doc-123/download") + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.download("doc-123") + + assert result == mock_pdf_content + mock_client.get.assert_called_once_with("/turbosign/documents/doc-123/download") + mock_http_client.get.assert_called_once_with(mock_api_response["downloadUrl"]) class TestVoid: @@ -354,11 +369,9 @@ def setup(self): async def test_void_document_with_reason(self): """Should void a document with reason""" mock_response = { - "data": { - "documentId": "doc-123", - "status": "voided", - "voidedAt": "2024-01-01T12:00:00Z" - } + "documentId": "doc-123", + "status": "voided", + "voidedAt": "2024-01-01T12:00:00Z" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -366,7 +379,7 @@ async def test_void_document_with_reason(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") + TurboSign.configure(api_key="test-key", org_id="test-org") result = await TurboSign.void_document("doc-123", "Document needs revision") assert result["documentId"] == "doc-123" @@ -388,11 +401,9 @@ def setup(self): async def test_resend_email_to_specific_recipients(self): """Should resend email to specific recipients""" mock_response = { - "data": { - "documentId": "doc-123", - "message": "Emails resent successfully", - "resentAt": "2024-01-01T12:00:00Z" - } + "documentId": "doc-123", + "message": "Emails resent successfully", + "resentAt": "2024-01-01T12:00:00Z" } with patch.object(TurboSign, '_get_client') as mock_get_client: @@ -400,7 +411,7 @@ async def test_resend_email_to_specific_recipients(self): mock_client.post = AsyncMock(return_value=mock_response) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") + TurboSign.configure(api_key="test-key", org_id="test-org") result = await TurboSign.resend_email("doc-123", ["rec-1", "rec-2"]) assert "resent" in result["message"] @@ -410,6 +421,48 @@ async def test_resend_email_to_specific_recipients(self): ) +class TestGetAuditTrail: + """Test get_audit_trail operation""" + + @pytest.fixture(autouse=True) + def setup(self): + TurboSign._client = None + + @pytest.mark.asyncio + async def test_get_audit_trail(self): + """Should get audit trail for document""" + mock_response = { + "documentId": "doc-123", + "entries": [ + { + "event": "document_created", + "actor": "user@example.com", + "timestamp": "2024-01-01T00:00:00Z", + "ipAddress": "192.168.1.1" + }, + { + "event": "email_sent", + "actor": "system", + "timestamp": "2024-01-01T00:01:00Z", + "details": {"recipientEmail": "signer@example.com"} + } + ] + } + + with patch.object(TurboSign, '_get_client') as mock_get_client: + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + TurboSign.configure(api_key="test-key", org_id="test-org") + result = await TurboSign.get_audit_trail("doc-123") + + assert result["documentId"] == "doc-123" + assert len(result["entries"]) == 2 + assert result["entries"][0]["event"] == "document_created" + mock_client.get.assert_called_once_with("/turbosign/documents/doc-123/audit-trail") + + class TestErrorHandling: """Test error handling""" @@ -418,39 +471,35 @@ def setup(self): TurboSign._client = None @pytest.mark.asyncio - async def test_throw_error_when_api_key_not_configured(self): - """Should throw error when API key is not configured""" - with pytest.raises(Exception): + async def test_throw_error_when_not_configured(self): + """Should throw error when not configured""" + with pytest.raises(RuntimeError, match="not configured"): await TurboSign.get_status("doc-123") @pytest.mark.asyncio - async def test_handle_api_errors_gracefully(self): - """Should handle API errors gracefully""" - api_error = Exception("Document not found") - + async def test_handle_not_found_error(self): + """Should handle not found errors""" with patch.object(TurboSign, '_get_client') as mock_get_client: mock_client = MagicMock() - mock_client.get = AsyncMock(side_effect=api_error) + mock_client.get = AsyncMock(side_effect=NotFoundError("Document not found")) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - with pytest.raises(Exception, match="Document not found"): + TurboSign.configure(api_key="test-key", org_id="test-org") + with pytest.raises(NotFoundError, match="Document not found"): await TurboSign.get_status("invalid-doc") @pytest.mark.asyncio - async def test_handle_validation_errors(self): + async def test_handle_validation_error(self): """Should handle validation errors""" - validation_error = Exception("Validation failed: Invalid email format") - with patch.object(TurboSign, '_get_client') as mock_get_client: mock_client = MagicMock() - mock_client.post = AsyncMock(side_effect=validation_error) + mock_client.post = AsyncMock(side_effect=ValidationError("Invalid email format")) mock_get_client.return_value = mock_client - TurboSign.configure(api_key="test-key") - with pytest.raises(Exception, match="Validation failed"): - await TurboSign.prepare_for_signing_single( + TurboSign.configure(api_key="test-key", org_id="test-org") + with pytest.raises(ValidationError, match="Invalid email"): + await TurboSign.send_signature( file_link="https://example.com/doc.pdf", - recipients=[{"name": "Test", "email": "invalid-email", "order": 1}], + recipients=[{"name": "Test", "email": "invalid-email", "signingOrder": 1}], fields=[] ) diff --git a/packages/ruby-sdk/Gemfile b/packages/ruby-sdk/Gemfile deleted file mode 100644 index bf239b38..00000000 --- a/packages/ruby-sdk/Gemfile +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gemspec - -group :development, :test do - gem "rspec", "~> 3.12" - gem "webmock", "~> 3.19" - gem "rake", "~> 13.0" -end diff --git a/packages/ruby-sdk/LICENSE b/packages/ruby-sdk/LICENSE deleted file mode 100644 index 907866ef..00000000 --- a/packages/ruby-sdk/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 TurboDocx - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/ruby-sdk/README.md b/packages/ruby-sdk/README.md deleted file mode 100644 index ea400711..00000000 --- a/packages/ruby-sdk/README.md +++ /dev/null @@ -1,370 +0,0 @@ -[![TurboDocx](./banner.png)](https://www.turbodocx.com) - -
- -# turbodocx-sdk - -**Official Ruby SDK for TurboDocx** - -[![Gem Version](https://img.shields.io/gem/v/turbodocx-sdk.svg)](https://rubygems.org/gems/turbodocx-sdk) -[![Gem Downloads](https://img.shields.io/gem/dt/turbodocx-sdk)](https://rubygems.org/gems/turbodocx-sdk) -[![Ruby](https://img.shields.io/badge/Ruby-3.0+-CC342D?logo=ruby&logoColor=white)](https://ruby-lang.org) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) - -[Documentation](https://www.turbodocx.com/docs) • [API Reference](https://www.turbodocx.com/docs/api) • [Examples](#examples) • [Discord](https://discord.gg/NYKwz4BcpX) - -
- ---- - -## Features - -- 🚀 **Production-Ready** — Battle-tested, processing thousands of documents daily -- 💎 **Idiomatic Ruby** — Clean, Ruby-style API with symbol-based responses -- 🔒 **Type-Safe** — Sorbet type signatures (RBI files included) -- 📝 **YARD Docs** — Comprehensive documentation -- 🧵 **Thread-Safe** — Safe for concurrent use -- 🤖 **100% n8n Parity** — Same operations as our n8n community nodes - ---- - -## Installation - -```bash -gem install turbodocx-sdk -``` - -Or add to your Gemfile: - -```ruby -gem 'turbodocx-sdk' -``` - -Then run: - -```bash -bundle install -``` - ---- - -## Quick Start - -```ruby -require 'turbodocx-sdk' - -# 1. Create client -client = TurboDocx::Client.new(api_key: 'your-api-key') - -# 2. Send document for signature -result = client.turbo_sign.prepare_for_signing_single( - file_link: 'https://example.com/contract.pdf', - recipients: [ - { name: 'John Doe', email: 'john@example.com', order: 1 } - ], - fields: [ - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 } - ] -) - -puts "Sign URL: #{result[:recipients][0][:sign_url]}" -``` - ---- - -## Configuration - -```ruby -# Basic client -client = TurboDocx::Client.new(api_key: 'your-api-key') - -# With options -client = TurboDocx::Client.new( - api_key: ENV['TURBODOCX_API_KEY'], - base_url: 'https://custom-api.example.com', # Optional - timeout: 30 # Optional (seconds) -) - -# Global configuration -TurboDocx.configure do |config| - config.api_key = ENV['TURBODOCX_API_KEY'] - config.timeout = 30 -end - -client = TurboDocx::Client.new -``` - ---- - -## API Reference - -### TurboSign - -#### `prepare_for_review` - -Upload a document for review without sending signature emails. - -```ruby -result = client.turbo_sign.prepare_for_review( - file_link: 'https://example.com/contract.pdf', - recipients: [ - { name: 'John Doe', email: 'john@example.com', order: 1 } - ], - fields: [ - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 } - ], - document_name: 'Service Agreement', # Optional - sender_name: 'Acme Corp', # Optional - sender_email: 'contracts@acme.com' # Optional -) - -puts "Preview URL: #{result[:preview_url]}" -puts "Document ID: #{result[:document_id]}" -``` - -#### `prepare_for_signing_single` - -Upload a document and immediately send signature request emails. - -```ruby -result = client.turbo_sign.prepare_for_signing_single( - file_link: 'https://example.com/contract.pdf', - recipients: [ - { name: 'Alice', email: 'alice@example.com', order: 1 }, - { name: 'Bob', email: 'bob@example.com', order: 2 } - ], - fields: [ - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }, - { type: 'signature', page: 1, x: 100, y: 600, width: 200, height: 50, recipientOrder: 2 } - ] -) - -result[:recipients].each do |r| - puts "#{r[:name]}: #{r[:sign_url]}" -end -``` - -#### `get_status` - -Check the current status of a document. - -```ruby -status = client.turbo_sign.get_status('doc-uuid-here') - -puts "Status: #{status[:status]}" # 'pending', 'completed', 'voided' - -status[:recipients].each do |r| - puts "#{r[:name]}: #{r[:status]}" -end -``` - -#### `download` - -Download the signed document. - -```ruby -pdf_bytes = client.turbo_sign.download('doc-uuid-here') - -# Save to file -File.write('signed-contract.pdf', pdf_bytes, mode: 'wb') -``` - -#### `void` - -Cancel a signature request. - -```ruby -client.turbo_sign.void('doc-uuid-here', reason: 'Contract terms changed') -``` - -#### `resend` - -Resend signature request emails. - -```ruby -client.turbo_sign.resend('doc-uuid-here', recipient_ids: ['recipient-uuid-1']) -``` - ---- - -## Field Types - -| Type | Description | Required | Auto-filled | -|:-----|:------------|:---------|:------------| -| `signature` | Signature field (draw or type) | Yes | No | -| `initials` | Initials field | Yes | No | -| `text` | Free-form text input | No | No | -| `date` | Date stamp | No | Yes (signing date) | -| `checkbox` | Checkbox / agreement | No | No | - ---- - -## Examples - -### Sequential Signing - -```ruby -result = client.turbo_sign.prepare_for_signing_single( - file_link: 'https://example.com/contract.pdf', - recipients: [ - { name: 'Employee', email: 'employee@company.com', order: 1 }, - { name: 'Manager', email: 'manager@company.com', order: 2 }, - { name: 'HR', email: 'hr@company.com', order: 3 } - ], - fields: [ - # Employee signs first - { type: 'signature', page: 1, x: 100, y: 400, width: 200, height: 50, recipientOrder: 1 }, - { type: 'date', page: 1, x: 320, y: 400, width: 100, height: 30, recipientOrder: 1 }, - # Manager signs second - { type: 'signature', page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 2 }, - # HR signs last - { type: 'signature', page: 1, x: 100, y: 600, width: 200, height: 50, recipientOrder: 3 } - ] -) -``` - -### Polling for Completion - -```ruby -def wait_for_completion(client, document_id, max_attempts: 60) - max_attempts.times do - status = client.turbo_sign.get_status(document_id) - - case status[:status] - when 'completed' - return client.turbo_sign.download(document_id) - when 'voided' - raise 'Document was voided' - end - - sleep 30 # Wait 30 seconds - end - - raise 'Timeout waiting for signatures' -end -``` - -### With Rails - -```ruby -# config/initializers/turbodocx.rb -TurboDocx.configure do |config| - config.api_key = Rails.application.credentials.turbodocx_api_key -end - -# app/services/contract_service.rb -class ContractService - def initialize - @client = TurboDocx::Client.new - end - - def send_for_signature(pdf_url:, recipients:, fields:) - @client.turbo_sign.prepare_for_signing_single( - file_link: pdf_url, - recipients: recipients, - fields: fields - ) - end -end - -# app/controllers/contracts_controller.rb -class ContractsController < ApplicationController - def create - service = ContractService.new - result = service.send_for_signature( - pdf_url: params[:pdf_url], - recipients: params[:recipients], - fields: params[:fields] - ) - - render json: { document_id: result[:document_id] } - end -end -``` - -### With Sidekiq - -```ruby -class SendContractJob - include Sidekiq::Job - - def perform(pdf_url, recipients, fields) - client = TurboDocx::Client.new(api_key: ENV['TURBODOCX_API_KEY']) - - result = client.turbo_sign.prepare_for_signing_single( - file_link: pdf_url, - recipients: recipients.map(&:symbolize_keys), - fields: fields.map(&:symbolize_keys) - ) - - # Schedule status check job - CheckSignatureStatusJob.perform_in(30.seconds, result[:document_id]) - end -end -``` - ---- - -## Error Handling - -```ruby -begin - result = client.turbo_sign.get_status('invalid-id') -rescue TurboDocx::Error => e - puts "Status: #{e.status_code}" - puts "Message: #{e.message}" - puts "Code: #{e.error_code}" -rescue => e - puts "Unexpected error: #{e.message}" -end -``` - -### Common Error Codes - -| Status | Meaning | -|:-------|:--------| -| `400` | Bad request — check your parameters | -| `401` | Unauthorized — check your API key | -| `404` | Document not found | -| `429` | Rate limited — slow down requests | -| `500` | Server error — retry with backoff | - ---- - -## Requirements - -- Ruby 3.0+ -- Faraday 2.x (included as dependency) - ---- - -## Related Packages - -| Package | Description | -|:--------|:------------| -| [@turbodocx/sdk (JS)](../js-sdk) | JavaScript/TypeScript SDK | -| [turbodocx-sdk (Python)](../py-sdk) | Python SDK | -| [@turbodocx/n8n-nodes-turbodocx](https://www.npmjs.com/package/@turbodocx/n8n-nodes-turbodocx) | n8n community nodes | - ---- - -## Support - -- 📖 [Documentation](https://www.turbodocx.com/docs) -- 💬 [Discord](https://discord.gg/NYKwz4BcpX) -- 🐛 [GitHub Issues](https://github.com/TurboDocx/SDK/issues) -- 📧 [Email Support](mailto:support@turbodocx.com) - ---- - -## License - -MIT — see [LICENSE](./LICENSE) - ---- - -
- -[![TurboDocx](./footer.png)](https://www.turbodocx.com) - -
diff --git a/packages/ruby-sdk/Rakefile b/packages/ruby-sdk/Rakefile deleted file mode 100644 index b6ae7341..00000000 --- a/packages/ruby-sdk/Rakefile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -require "bundler/gem_tasks" -require "rspec/core/rake_task" - -RSpec::Core::RakeTask.new(:spec) - -task default: :spec diff --git a/packages/ruby-sdk/banner.png b/packages/ruby-sdk/banner.png deleted file mode 100644 index e1e45c21..00000000 Binary files a/packages/ruby-sdk/banner.png and /dev/null differ diff --git a/packages/ruby-sdk/footer.png b/packages/ruby-sdk/footer.png deleted file mode 100644 index 7a6ced08..00000000 Binary files a/packages/ruby-sdk/footer.png and /dev/null differ diff --git a/packages/ruby-sdk/lib/turbodocx.rb b/packages/ruby-sdk/lib/turbodocx.rb deleted file mode 100644 index ba124ac1..00000000 --- a/packages/ruby-sdk/lib/turbodocx.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require_relative "turbodocx/version" -require_relative "turbodocx/errors" -require_relative "turbodocx/http_client" -require_relative "turbodocx/turbo_sign" -require_relative "turbodocx/client" - -module TurboDocx - class << self - attr_accessor :api_key, :base_url - - def configure - yield self - end - end -end diff --git a/packages/ruby-sdk/lib/turbodocx/client.rb b/packages/ruby-sdk/lib/turbodocx/client.rb deleted file mode 100644 index 75f8f580..00000000 --- a/packages/ruby-sdk/lib/turbodocx/client.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module TurboDocx - # Main client for TurboDocx API - class Client - attr_reader :turbo_sign - - def initialize(api_key: nil, access_token: nil, base_url: nil) - api_key ||= TurboDocx.api_key - access_token ||= ENV["TURBODOCX_ACCESS_TOKEN"] - base_url ||= TurboDocx.base_url - - if (api_key.nil? || api_key.empty?) && (access_token.nil? || access_token.empty?) - raise ArgumentError, "API key or access token is required" - end - - http_client = HttpClient.new( - api_key: api_key, - access_token: access_token, - base_url: base_url - ) - - @turbo_sign = TurboSign.new(http_client) - end - end -end diff --git a/packages/ruby-sdk/lib/turbodocx/errors.rb b/packages/ruby-sdk/lib/turbodocx/errors.rb deleted file mode 100644 index 99ea8c3b..00000000 --- a/packages/ruby-sdk/lib/turbodocx/errors.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module TurboDocx - class Error < StandardError - attr_reader :status_code, :code - - def initialize(message, status_code: nil, code: nil) - @status_code = status_code - @code = code - super(message) - end - end - - class AuthenticationError < Error; end - class ValidationError < Error; end - class NotFoundError < Error; end -end diff --git a/packages/ruby-sdk/lib/turbodocx/http_client.rb b/packages/ruby-sdk/lib/turbodocx/http_client.rb deleted file mode 100644 index 013a9b1b..00000000 --- a/packages/ruby-sdk/lib/turbodocx/http_client.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -require "faraday" -require "faraday/multipart" -require "json" - -module TurboDocx - class HttpClient - DEFAULT_BASE_URL = "https://api.turbodocx.com" - - def initialize(api_key:, access_token: nil, base_url: nil) - @api_key = api_key - @access_token = access_token - @base_url = (base_url || DEFAULT_BASE_URL).chomp("/") - end - - def get(path) - response = connection.get(path) - handle_response(response) - end - - def get_raw(path) - response = connection.get(path) - handle_raw_response(response) - end - - def post(path, body) - response = connection.post(path) do |req| - req.headers["Content-Type"] = "application/json" - req.body = body.to_json - end - handle_response(response) - end - - def upload_file(path, file, file_name, form_data) - response = multipart_connection.post(path) do |req| - req.body = form_data.merge( - file: Faraday::Multipart::FilePart.new( - StringIO.new(file), - "application/octet-stream", - file_name - ) - ) - end - handle_response(response) - end - - private - - def connection - @connection ||= Faraday.new(url: @base_url) do |f| - f.request :json - f.response :json - f.adapter Faraday.default_adapter - f.headers.merge!(auth_headers) - end - end - - def multipart_connection - @multipart_connection ||= Faraday.new(url: @base_url) do |f| - f.request :multipart - f.response :json - f.adapter Faraday.default_adapter - f.headers.merge!(auth_headers) - end - end - - def auth_headers - headers = {} - headers["X-API-Key"] = @api_key if @api_key - headers["Authorization"] = "Bearer #{@access_token}" if @access_token - headers - end - - def handle_response(response) - return response.body if response.success? - - handle_error(response) - end - - def handle_raw_response(response) - return response.body if response.success? - - handle_error(response) - end - - def handle_error(response) - body = response.body || {} - message = body["message"] || "API Error" - code = body["code"] - - raise Error.new(message, status_code: response.status, code: code) - end - end -end diff --git a/packages/ruby-sdk/lib/turbodocx/turbo_sign.rb b/packages/ruby-sdk/lib/turbodocx/turbo_sign.rb deleted file mode 100644 index 37999808..00000000 --- a/packages/ruby-sdk/lib/turbodocx/turbo_sign.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module TurboDocx - # TurboSign client for digital signature operations - # with 100% parity with n8n-nodes-turbodocx - class TurboSign - def initialize(http_client) - @http = http_client - end - - # Prepare document for review without sending emails. - # Use this to preview field placement before sending. - def prepare_for_review( - file: nil, - file_name: nil, - file_link: nil, - deliverable_id: nil, - template_id: nil, - recipients:, - fields:, - document_name: nil, - document_description: nil, - sender_name: nil, - sender_email: nil, - cc_emails: nil - ) - form_data = build_form_data( - recipients: recipients, - fields: fields, - document_name: document_name, - document_description: document_description, - sender_name: sender_name, - sender_email: sender_email, - cc_emails: cc_emails - ) - - response = if file - @http.upload_file( - "/turbosign/single/prepare-for-review", - file, - file_name || "document.pdf", - form_data - ) - else - form_data[:fileLink] = file_link if file_link - form_data[:deliverableId] = deliverable_id if deliverable_id - form_data[:templateId] = template_id if template_id - @http.post("/turbosign/single/prepare-for-review", form_data) - end - - symbolize_keys(response["data"]) - end - - # Prepare document for signing and send emails in a single call. - # This is the n8n-equivalent "Prepare for Signing" operation. - def prepare_for_signing_single( - file: nil, - file_name: nil, - file_link: nil, - deliverable_id: nil, - template_id: nil, - recipients:, - fields:, - document_name: nil, - document_description: nil, - sender_name: nil, - sender_email: nil, - cc_emails: nil - ) - form_data = build_form_data( - recipients: recipients, - fields: fields, - document_name: document_name, - document_description: document_description, - sender_name: sender_name, - sender_email: sender_email, - cc_emails: cc_emails - ) - - response = if file - @http.upload_file( - "/turbosign/single/prepare-for-signing", - file, - file_name || "document.pdf", - form_data - ) - else - form_data[:fileLink] = file_link if file_link - form_data[:deliverableId] = deliverable_id if deliverable_id - form_data[:templateId] = template_id if template_id - @http.post("/turbosign/single/prepare-for-signing", form_data) - end - - symbolize_keys(response["data"]) - end - - # Get the status of a document - def get_status(document_id) - response = @http.get("/turbosign/documents/#{document_id}/status") - symbolize_keys(response["data"]) - end - - # Download the signed document - def download(document_id) - @http.get_raw("/turbosign/documents/#{document_id}/download") - end - - # Void a document (cancel signature request) - def void_document(document_id, reason) - response = @http.post( - "/turbosign/documents/#{document_id}/void", - { reason: reason } - ) - symbolize_keys(response["data"]) - end - - # Resend signature request email to recipients - def resend_email(document_id, recipient_ids) - response = @http.post( - "/turbosign/documents/#{document_id}/resend-email", - { recipientIds: recipient_ids } - ) - symbolize_keys(response["data"]) - end - - private - - def build_form_data( - recipients:, - fields:, - document_name:, - document_description:, - sender_name:, - sender_email:, - cc_emails: - ) - form_data = { - recipients: recipients.to_json, - fields: fields.to_json - } - - form_data[:documentName] = document_name if document_name - form_data[:documentDescription] = document_description if document_description - form_data[:senderName] = sender_name if sender_name - form_data[:senderEmail] = sender_email if sender_email - form_data[:ccEmails] = cc_emails.join(",") if cc_emails&.any? - - form_data - end - - def symbolize_keys(hash) - return hash unless hash.is_a?(Hash) - - hash.transform_keys(&:to_sym).transform_values do |v| - case v - when Hash then symbolize_keys(v) - when Array then v.map { |item| item.is_a?(Hash) ? symbolize_keys(item) : item } - else v - end - end - end - end -end diff --git a/packages/ruby-sdk/lib/turbodocx/version.rb b/packages/ruby-sdk/lib/turbodocx/version.rb deleted file mode 100644 index b361a6f3..00000000 --- a/packages/ruby-sdk/lib/turbodocx/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module TurboDocx - VERSION = "1.0.0" -end diff --git a/packages/ruby-sdk/spec/spec_helper.rb b/packages/ruby-sdk/spec/spec_helper.rb deleted file mode 100644 index bee71575..00000000 --- a/packages/ruby-sdk/spec/spec_helper.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require "webmock/rspec" -require "turbodocx" - -RSpec.configure do |config| - config.expect_with :rspec do |expectations| - expectations.include_chain_clauses_in_custom_matcher_descriptions = true - end - - config.mock_with :rspec do |mocks| - mocks.verify_partial_doubles = true - end - - config.shared_context_metadata_behavior = :apply_to_host_groups - config.filter_run_when_matching :focus - config.disable_monkey_patching! - config.warnings = true - - config.default_formatter = "doc" if config.files_to_run.one? - - config.order = :random - Kernel.srand config.seed - - config.before(:each) do - WebMock.reset! - end -end diff --git a/packages/ruby-sdk/spec/turbo_sign_spec.rb b/packages/ruby-sdk/spec/turbo_sign_spec.rb deleted file mode 100644 index 2b9f7966..00000000 --- a/packages/ruby-sdk/spec/turbo_sign_spec.rb +++ /dev/null @@ -1,385 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" -require "json" - -# TurboSign Module Tests -# -# Tests for 100% parity with n8n-nodes-turbodocx operations: -# - prepare_for_review -# - prepare_for_signing_single -# - get_status -# - download -# - void_document -# - resend_email - -RSpec.describe TurboDocx::TurboSign do - let(:api_key) { "test-api-key" } - let(:base_url) { "https://api.turbodocx.com" } - let(:client) { TurboDocx::Client.new(api_key: api_key, base_url: base_url) } - - let(:mock_recipients) do - [{ name: "John Doe", email: "john@example.com", order: 1 }] - end - - let(:mock_fields) do - [{ type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientOrder: 1 }] - end - - # ============================================ - # Configure Tests (2) - # ============================================ - - describe "configuration" do - it "should configure the client with API key" do - test_client = TurboDocx::Client.new(api_key: "test-api-key") - expect(test_client).not_to be_nil - expect(test_client.turbo_sign).not_to be_nil - end - - it "should configure with custom base URL" do - test_client = TurboDocx::Client.new( - api_key: "test-api-key", - base_url: "https://custom-api.example.com" - ) - expect(test_client).not_to be_nil - end - end - - # ============================================ - # PrepareForReview Tests (5) - # ============================================ - - describe "#prepare_for_review" do - it "should prepare document for review with file upload" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-review") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-123", - status: "review_ready", - previewUrl: "https://preview.example.com/doc-123" - } - }.to_json - ) - - result = client.turbo_sign.prepare_for_review( - file: "%PDF-mock-content", - file_name: "contract.pdf", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-123") - expect(result[:status]).to eq("review_ready") - expect(result[:previewUrl]).not_to be_nil - end - - it "should prepare document for review with file URL" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-review") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-456", - status: "review_ready", - previewUrl: "https://preview.example.com/doc-456" - } - }.to_json - ) - - result = client.turbo_sign.prepare_for_review( - file_link: "https://storage.example.com/contract.pdf", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-456") - end - - it "should prepare document for review with deliverable ID" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-review") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { documentId: "doc-789", status: "review_ready" } - }.to_json - ) - - result = client.turbo_sign.prepare_for_review( - deliverable_id: "deliverable-abc", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-789") - end - - it "should prepare document for review with template ID" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-review") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { documentId: "doc-template", status: "review_ready" } - }.to_json - ) - - result = client.turbo_sign.prepare_for_review( - template_id: "template-xyz", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-template") - end - - it "should include optional fields in request" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-review") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { documentId: "doc-optional", status: "review_ready" } - }.to_json - ) - - result = client.turbo_sign.prepare_for_review( - file_link: "https://example.com/doc.pdf", - recipients: mock_recipients, - fields: mock_fields, - document_name: "Test Contract", - document_description: "A test contract", - sender_name: "Sales Team", - sender_email: "sales@company.com", - cc_emails: ["admin@company.com", "legal@company.com"] - ) - - expect(result[:documentId]).to eq("doc-optional") - end - end - - # ============================================ - # PrepareForSigningSingle Tests (2) - # ============================================ - - describe "#prepare_for_signing_single" do - it "should prepare document for signing and send emails" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-signing") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-123", - status: "sent", - recipients: [ - { - id: "rec-1", - name: "John Doe", - email: "john@example.com", - status: "pending", - signUrl: "https://sign.example.com/rec-1" - } - ] - } - }.to_json - ) - - result = client.turbo_sign.prepare_for_signing_single( - file_link: "https://storage.example.com/contract.pdf", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-123") - expect(result[:status]).to eq("sent") - expect(result[:recipients]).not_to be_empty - expect(result[:recipients][0][:signUrl]).not_to be_nil - end - - it "should handle file upload for signing" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-signing") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-upload", - status: "sent", - recipients: [] - } - }.to_json - ) - - result = client.turbo_sign.prepare_for_signing_single( - file: "%PDF-mock-content", - file_name: "contract.pdf", - recipients: mock_recipients, - fields: mock_fields - ) - - expect(result[:documentId]).to eq("doc-upload") - end - end - - # ============================================ - # GetStatus Test (1) - # ============================================ - - describe "#get_status" do - it "should get document status" do - stub_request(:get, "#{base_url}/turbosign/documents/doc-123/status") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-123", - status: "pending", - name: "Test Document", - recipients: [ - { id: "rec-1", name: "John Doe", email: "john@example.com", status: "pending" } - ], - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z" - } - }.to_json - ) - - result = client.turbo_sign.get_status("doc-123") - - expect(result[:documentId]).to eq("doc-123") - expect(result[:status]).to eq("pending") - expect(result[:name]).to eq("Test Document") - end - end - - # ============================================ - # Download Test (1) - # ============================================ - - describe "#download" do - it "should download signed document" do - pdf_content = "%PDF-mock-content" - - stub_request(:get, "#{base_url}/turbosign/documents/doc-123/download") - .to_return( - status: 200, - headers: { "Content-Type" => "application/pdf" }, - body: pdf_content - ) - - result = client.turbo_sign.download("doc-123") - - expect(result).to eq(pdf_content) - end - end - - # ============================================ - # Void Test (1) - # ============================================ - - describe "#void_document" do - it "should void a document with reason" do - stub_request(:post, "#{base_url}/turbosign/documents/doc-123/void") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-123", - status: "voided", - voidedAt: "2024-01-01T12:00:00Z" - } - }.to_json - ) - - result = client.turbo_sign.void_document("doc-123", "Document needs revision") - - expect(result[:documentId]).to eq("doc-123") - expect(result[:status]).to eq("voided") - end - end - - # ============================================ - # Resend Test (1) - # ============================================ - - describe "#resend_email" do - it "should resend email to specific recipients" do - stub_request(:post, "#{base_url}/turbosign/documents/doc-123/resend-email") - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - data: { - documentId: "doc-123", - message: "Emails resent successfully", - resentAt: "2024-01-01T12:00:00Z" - } - }.to_json - ) - - result = client.turbo_sign.resend_email("doc-123", ["rec-1", "rec-2"]) - - expect(result[:message]).to include("resent") - end - end - - # ============================================ - # Error Handling Tests (3) - # ============================================ - - describe "error handling" do - it "should throw error when API key is not configured" do - expect { TurboDocx::Client.new }.to raise_error(ArgumentError) - end - - it "should handle API errors gracefully" do - stub_request(:get, "#{base_url}/turbosign/documents/invalid-doc/status") - .to_return( - status: 404, - headers: { "Content-Type" => "application/json" }, - body: { - message: "Document not found", - code: "DOCUMENT_NOT_FOUND" - }.to_json - ) - - expect { client.turbo_sign.get_status("invalid-doc") } - .to raise_error(TurboDocx::Error) do |error| - expect(error.status_code).to eq(404) - expect(error.message).to eq("Document not found") - expect(error.code).to eq("DOCUMENT_NOT_FOUND") - end - end - - it "should handle validation errors" do - stub_request(:post, "#{base_url}/turbosign/single/prepare-for-signing") - .to_return( - status: 400, - headers: { "Content-Type" => "application/json" }, - body: { - message: "Validation failed: Invalid email format", - code: "VALIDATION_ERROR" - }.to_json - ) - - expect do - client.turbo_sign.prepare_for_signing_single( - file_link: "https://example.com/doc.pdf", - recipients: [{ name: "Test", email: "invalid-email", order: 1 }], - fields: [] - ) - end.to raise_error(TurboDocx::Error) do |error| - expect(error.status_code).to eq(400) - expect(error.message).to include("Validation") - end - end - end -end diff --git a/packages/ruby-sdk/turbodocx.gemspec b/packages/ruby-sdk/turbodocx.gemspec deleted file mode 100644 index a95378df..00000000 --- a/packages/ruby-sdk/turbodocx.gemspec +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -Gem::Specification.new do |spec| - spec.name = "turbodocx" - spec.version = "1.0.0" - spec.authors = ["TurboDocx Team"] - spec.email = ["support@turbodocx.com"] - - spec.summary = "Official Ruby SDK for TurboDocx API" - spec.description = "Ruby SDK for TurboDocx API - Document generation and digital signatures with 100% n8n parity" - spec.homepage = "https://github.com/TurboDocx/SDK" - spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" - - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/TurboDocx/SDK/tree/main/packages/ruby-sdk" - spec.metadata["changelog_uri"] = "https://github.com/TurboDocx/SDK/blob/main/packages/ruby-sdk/CHANGELOG.md" - - spec.files = Dir.chdir(__dir__) do - `git ls-files -z`.split("\x0").reject do |f| - (File.expand_path(f) == __FILE__) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) - end - end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] - - spec.add_dependency "faraday", "~> 2.0" - spec.add_dependency "faraday-multipart", "~> 1.0" - - spec.add_development_dependency "rspec", "~> 3.12" - spec.add_development_dependency "webmock", "~> 3.19" - spec.add_development_dependency "rake", "~> 13.0" -end