Add signature workflow models, services, and configurations

Introduced new models (`SignatureDto`, `SignatureCaptureDto`, `EnvelopeReceiverDto`) to support a signature-based workflow. Added services for handling API interactions (`SignatureService`, `AuthService`, `DocumentService`, `EnvelopeReceiverService`, `SignatureCacheService`).

Enhanced configuration with `ApiOptions` and `PdfViewerOptions`. Integrated DevExpress features with custom data connection providers, in-memory report storage, and font loading utilities.

Marked `AnnotationDto` and `AnnotationService` as `[Obsolete]` in favor of newer implementations. Added detailed documentation for coordinate systems, unit conversions, and usage scenarios.
This commit is contained in:
2026-06-12 14:02:55 +02:00
parent d599fe3156
commit 1f889d8b58
21 changed files with 946 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
namespace EnvelopeGenerator.WebUI.Client.Models;
/// <summary>
/// Represents a pre-assigned signature annotation position on a specific page.
/// <br/><br/>
/// <b>Coordinate unit (X, Y):</b> Inches (GdPicture14 native unit),
/// origin at the <b>top-left</b> corner of the page, both axes increase downward/rightward.
/// <br/><br/>
/// <b>Conversion to DevExpress:</b> Multiply by 100 (DX uses 1/100 inch).
/// Convert: <c>xDX = xInches * 100.0</c>
/// <br/>
/// <b>Conversion to PDF Points:</b> Multiply by 72 (1 inch = 72 points).
/// Convert: <c>xPt = xInches * 72.0</c>
/// <br/>
/// <b>Y-axis for PDF (bottom-left origin):</b> Flip required for iText7.
/// Convert: <c>yPt = (pageHeightInches - yInches - elemHeightInches) * 72.0</c>
/// </summary>
[Obsolete("Use SignatureDto with SignatureService.")]
public record AnnotationDto
{
/// <summary>Unique identifier of the annotation.</summary>
public long Id { get; init; }
/// <summary>1-based page number within the document.</summary>
public int Page { get; init; }
/// <summary>Horizontal position in INCHES from the left edge of the page.</summary>
public double X { get; init; }
/// <summary>Vertical position in INCHES from the top edge of the page.</summary>
public double Y { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace EnvelopeGenerator.WebUI.Client.Models.Constants
{
public enum SenderAppType
{
LegacyFormApp = 0,
ReceiverUIBlazorApp = 1
}
}

View File

@@ -0,0 +1,65 @@
namespace EnvelopeGenerator.WebUI.Client.Models.Constants;
/// <summary>
/// Represents the unit of measurement for coordinate values in signature positioning.
/// Used for converting coordinates between different systems (GdPicture14, PDF.js, iText7).
/// </summary>
public enum UnitOfLength
{
/// <summary>
/// Inch unit (1 inch = 25.4 mm).
/// This is the native unit used by GdPicture14 (EnvelopeGenerator.Form - Legacy VB.NET app).
/// Database stores all coordinates (X, Y, Width, Height) in INCHES.
/// </summary>
/// <remarks>
/// <b>Source:</b> GdPicture14.Annotations.AnnotationStickyNote uses INCHES natively.
/// <br/>
/// <b>Evidence:</b> VB.NET code directly assigns database values to annotation properties without conversion:
/// <code>
/// oAnnotation.Left = CSng(pElement.X) ' Direct assignment ? INCHES
/// oAnnotation.Top = CSng(pElement.Y)
/// </code>
/// <b>Standard Page Dimensions:</b>
/// <list type="bullet">
/// <item>A4: 8.27" × 11.69" (210mm × 297mm)</item>
/// <item>Letter: 8.5" × 11"</item>
/// </list>
/// </remarks>
Inch = 0,
/// <summary>
/// PDF Point unit (1 point = 1/72 inch).
/// This is the standard unit used by PDF specification and PDF.js viewer.
/// </summary>
/// <remarks>
/// <b>Definition:</b> According to PDF specification and Microsoft documentation:
/// <br/>
/// <i>"PDF pages are sized in point units. 1 pt == 1/72 inch"</i>
/// <br/><br/>
/// <b>Conversion Formula:</b>
/// <code>
/// points = inches * 72.0
/// inches = points / 72.0
/// </code>
/// <b>Important:</b> Point ? Pixel!
/// <list type="bullet">
/// <item><b>Point (pt):</b> Device-independent unit (always 1/72 inch)</item>
/// <item><b>Pixel (px):</b> Device-dependent unit (varies with screen DPI)</item>
/// <item>At 72 DPI: 1 point = 1 pixel (coincidence)</item>
/// <item>At 96 DPI: 1 point ? 1.33 pixels</item>
/// <item>At 300 DPI: 1 point ? 4.17 pixels</item>
/// </list>
/// <b>Standard Page Dimensions (in points):</b>
/// <list type="bullet">
/// <item>A4: 595 × 842 points (8.27" × 11.69" × 72)</item>
/// <item>Letter: 612 × 792 points (8.5" × 11" × 72)</item>
/// </list>
/// <b>Usage in EnvelopeGenerator:</b>
/// <list type="bullet">
/// <item>PDF.js viewer expects coordinates in points</item>
/// <item>iText7 library uses points for PDF manipulation</item>
/// <item>PSPDFKit (Web) uses points for annotation placement</item>
/// </list>
/// </remarks>
Point
}

View File

@@ -0,0 +1,105 @@
namespace EnvelopeGenerator.WebUI.Client.Models;
/// <summary>
/// Client-side model for the envelope receiver returned by
/// <c>GET api/EnvelopeReceiver/{envelopeKey}</c>.
/// </summary>
public record EnvelopeReceiverDto
{
public int EnvelopeId { get; init; }
public int ReceiverId { get; init; }
public int Sequence { get; init; }
public string? Name { get; init; }
public string? JobTitle { get; init; }
public string? CompanyName { get; init; }
public string? PrivateMessage { get; init; }
public DateTime AddedWhen { get; init; }
public DateTime? ChangedWhen { get; init; }
public bool HasPhoneNumber { get; init; }
public EnvelopeClientDto? Envelope { get; init; }
public ReceiverClientDto? Receiver { get; init; }
}
/// <summary>
/// Client-side model for the envelope data embedded in <see cref="EnvelopeReceiverDto"/>.
/// </summary>
public record EnvelopeClientDto
{
public int Id { get; init; }
public int UserId { get; init; }
public int Status { get; init; }
public string StatusName { get; init; } = string.Empty;
public string Uuid { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public DateTime AddedWhen { get; init; }
public DateTime? ChangedWhen { get; init; }
public string Language { get; init; } = "de-DE";
public int? EnvelopeTypeId { get; init; }
public string? EnvelopeTypeTitle { get; init; }
public int? ContractType { get; init; }
public int? CertificationType { get; init; }
public bool UseAccessCode { get; init; }
public bool TFAEnabled { get; init; }
public IEnumerable<DocumentClientDto>? Documents { get; init; }
public EnvelopeSenderDto? User { get; init; }
}
/// <summary>
/// Sender (user) information embedded in <see cref="EnvelopeClientDto"/>.
/// </summary>
public record EnvelopeSenderDto
{
public int Id { get; init; }
public string? Username { get; init; }
public string? FullName { get; init; }
public string? Email { get; init; }
}
/// <summary>
/// Client-side model for a document embedded in <see cref="EnvelopeClientDto"/>.
/// </summary>
public record DocumentClientDto
{
public int Id { get; init; }
public int EnvelopeId { get; init; }
public DateTime AddedWhen { get; init; }
public IEnumerable<SignatureClientDto>? Elements { get; init; }
}
/// <summary>
/// Client-side model for a signature/annotation element embedded in <see cref="DocumentClientDto"/>.
/// </summary>
public record SignatureClientDto
{
public int Id { get; init; }
public int DocumentId { get; init; }
public int ReceiverId { get; init; }
public int ElementType { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double Width { get; init; }
public double Height { get; init; }
public int Page { get; init; }
public bool Required { get; init; }
public string? Tooltip { get; init; }
public bool ReadOnly { get; init; }
public int AnnotationIndex { get; init; }
public DateTime AddedWhen { get; init; }
public DateTime? ChangedWhen { get; init; }
}
/// <summary>
/// Client-side model for the receiver data embedded in <see cref="EnvelopeReceiverDto"/>.
/// </summary>
public record ReceiverClientDto
{
public int Id { get; init; }
public string? EmailAddress { get; init; }
public string? Signature { get; init; }
public DateTime AddedWhen { get; init; }
public DateTime? TfaRegDeadline { get; init; }
}

View File

@@ -0,0 +1,61 @@
namespace EnvelopeGenerator.WebUI.Client.Models;
/// <summary>
/// Represents a captured signature with metadata created by the receiver in the signature popup.
/// This model holds the signature image (as base64 data URL) along with signer information
/// used for rendering applied signatures on the PDF canvas.
/// </summary>
/// <remarks>
/// <b>Used in:</b> EnvelopeViewer.razor signature popup workflow
/// <br/>
/// <b>Creation:</b> User draws/types/uploads signature and fills required fields
/// <br/>
/// <b>Storage:</b> Session-only (Blazor component state, lost on page refresh)
/// <br/>
/// <b>Rendering:</b> Applied signatures display: Image + Separator + Name/Position/Place/Date
/// </remarks>
public sealed record SignatureCaptureDto
{
/// <summary>
/// Base64-encoded data URL of the signature image.
/// <br/>
/// <b>Format:</b> <c>data:image/png;base64,iVBORw0KG...</c>
/// <br/>
/// <b>Source:</b> Canvas.toDataURL() from signature pad (draw/text/image tabs)
/// <br/>
/// <b>Usage:</b> Set as <c>img.src</c> in applied signature overlay
/// </summary>
public required string DataUrl { get; init; }
/// <summary>
/// Full name of the signer (first and last name).
/// <br/>
/// <b>Required:</b> Yes (validated in popup)
/// <b>Display:</b> Bold text in applied signature block
/// <br/>
/// <b>Example:</b> "Max Mustermann"
/// </summary>
public required string FullName { get; init; }
/// <summary>
/// Job title or position of the signer.
/// <br/>
/// <b>Required:</b> No (optional field)
/// <br/>
/// <b>Display:</b> Normal weight text between name and place/date
/// <br/>
/// <b>Example:</b> "Geschäftsführer" or empty string
/// </summary>
public string Position { get; init; } = string.Empty;
/// <summary>
/// Location/place where the signature was created.
/// <br/>
/// <b>Required:</b> Yes (validated in popup)
/// <br/>
/// <b>Display:</b> Shown with current date in German format (dd.MM.yyyy)
/// <br/>
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
/// </summary>
public required string Place { get; init; }
}

View File

@@ -0,0 +1,101 @@
using EnvelopeGenerator.WebUI.Client.Models.Constants;
namespace EnvelopeGenerator.WebUI.Client.Models;
/// <summary>
/// Represents a signature position on a PDF page.
/// Coordinates stored in INCHES (GdPicture14 native unit).
/// Origin: Top-left corner, X increases right, Y increases down.
/// </summary>
public class SignatureDto
{
/// <summary>Unique identifier.</summary>
public int Id { get; init; }
private double _x;
private double _y;
/// <summary>Horizontal position in INCHES from left edge.</summary>
public double X
{
get => _x * Factor;
init => _x = value;
}
/// <summary>Vertical position in INCHES from top edge.</summary>
public double Y
{
get => _y * Factor;
init => _y = value;
}
/// <summary>1-based page number.</summary>
public int Page { get; init; }
/// <summary>Sender application type that created this signature.</summary>
public SenderAppType SenderAppType { get; init; }
private UnitOfLength _unitOfLength;
public SignatureDto Convert(UnitOfLength unitOfLength)
{
_unitOfLength = unitOfLength;
return this;
}
public double Factor
{
get
{
if (SenderAppType != SenderAppType.LegacyFormApp)
{
throw new NotImplementedException(
$"SenderAppType '{SenderAppType}' is not yet implemented. " +
$"Currently, only '{nameof(SenderAppType.LegacyFormApp)}' is supported. " +
$"Future implementations will handle '{nameof(SenderAppType.ReceiverUIBlazorApp)}' and other types.");
}
// LegacyFormApp uses GdPicture14 with INCHES
return _unitOfLength switch
{
UnitOfLength.Inch => 1.0, // No conversion needed: INCHES ? INCHES
UnitOfLength.Point => 72.0, // INCHES ? PDF Points: 1 inch = 72 points (PDF standard, NOT pixels!)
_ => throw new InvalidOperationException(
$"Unknown UnitOfLength: {_unitOfLength}. Expected '{nameof(UnitOfLength.Inch)}' or '{nameof(UnitOfLength.Point)}'.")
};
}
}
}
public static class SignatureDtoExtensions
{
/// <summary>
/// Converts all signatures in the collection to the specified unit of length.
/// </summary>
/// <typeparam name="T">Type of the collection (IEnumerable, List, etc.)</typeparam>
/// <param name="signatures">Collection of SignatureDto objects to convert.</param>
/// <param name="unitOfLength">Target unit of measurement (Inch or Point).</param>
/// <returns>The same collection with all signatures converted to the specified unit.</returns>
/// <exception cref="ArgumentNullException">Thrown when signatures collection is null.</exception>
/// <remarks>
/// <b>Usage:</b>
/// <code>
/// var signatures = await SignatureService.GetAsync(envelopeKey);
/// var convertedSignatures = signatures.ConvertAll(UnitOfLength.Point);
/// </code>
/// <b>Note:</b> This method modifies each SignatureDto object in place and returns the same collection.
/// </remarks>
public static T Convert<T>(this T signatures, UnitOfLength unitOfLength)
where T : IEnumerable<SignatureDto>
{
if (signatures == null)
throw new ArgumentNullException(nameof(signatures));
foreach (var signature in signatures)
{
signature.Convert(unitOfLength);
}
return signatures;
}
}

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.WebUI.Client.Options;
public class ApiOptions
{
public const string SectionName = "ApiOptions";
public string BaseUrl { get; set; } = string.Empty;
public bool UsePredefinedReports { get; set; } = false;
}

View File

@@ -0,0 +1,71 @@
namespace EnvelopeGenerator.WebUI.Client.Options;
public class PdfViewerOptions
{
public const string SectionName = "PdfViewerOptions";
/// <summary>
/// Base scale for thumbnail rendering (0.2 - 1.5 recommended)
/// Higher values = better quality but slower rendering
/// Default: 0.75
/// </summary>
public double ThumbnailBaseScale { get; set; } = 0.75;
/// <summary>
/// Enable HiDPI/Retina support for thumbnails
/// Default: true
/// </summary>
public bool ThumbnailEnableHiDPI { get; set; } = true;
/// <summary>
/// Maximum device pixel ratio multiplier for thumbnails (1.0 - 3.0)
/// Caps DPR to avoid excessive memory usage on 4K+ displays
/// Default: 2.0
/// </summary>
public double ThumbnailMaxDPR { get; set; } = 2.0;
/// <summary>
/// Enable HiDPI/Retina support for main PDF canvas
/// Default: true
/// </summary>
public bool MainCanvasEnableHiDPI { get; set; } = true;
/// <summary>
/// Maximum device pixel ratio multiplier for main canvas (1.0 - 3.0)
/// Default: 2.0
/// </summary>
public double MainCanvasMaxDPR { get; set; } = 2.0;
/// <summary>
/// Enable smooth zoom transition (fade effect)
/// Default: true
/// </summary>
public bool EnableSmoothZoom { get; set; } = true;
/// <summary>
/// Zoom transition duration in milliseconds (50 - 500)
/// Default: 150
/// </summary>
public int ZoomTransitionDuration { get; set; } = 150;
/// <summary>
/// Opacity during rendering (0.0 - 1.0)
/// Lower values = more visible fade effect
/// Default: 0.85
/// </summary>
public double RenderingOpacity { get; set; } = 0.85;
/// <summary>
/// Delay between thumbnail renders in milliseconds (10 - 200)
/// Higher values = less browser stress, slower initial load
/// Default: 50
/// </summary>
public int ThumbnailRenderDelay { get; set; } = 50;
/// <summary>
/// Zoom step percentage (1 - 50)
/// Controls how much zoom changes per click or scroll
/// Default: 5 (5% per step)
/// </summary>
public int ZoomStepPercentage { get; set; } = 5;
}

View File

@@ -0,0 +1,33 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.WebUI.Client.Models;
using EnvelopeGenerator.WebUI.Client.Options;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.WebUI.Client.Services;
/// <summary>
/// Retrieves annotation positions from the API.
/// The URL is composed as <c>{BaseUrl}/api/Annotation/{envelopeKey}</c>.
/// During development, <c>BaseUrl</c> is empty so the request resolves to the
/// YARP-proxied route on the same origin, which currently serves
/// <c>fake-data/annotations.json</c>. To switch to real data, update the
/// YARP route in <c>yarp.json</c> no code change required.
/// </summary>
[Obsolete("Use SignatureService.")]
public class AnnotationService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<AnnotationDto>> GetAnnotationsAsync(string envelopeKey, CancellationToken cancel = default)
{
var url = $"{apiOptions.Value.BaseUrl}/api/Annotation/{Uri.EscapeDataString(envelopeKey)}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
}

View File

@@ -0,0 +1,26 @@
namespace EnvelopeGenerator.WebUI.Client.Services;
/// <summary>
/// Provides application version for cache busting static assets.
/// Version is automatically incremented on each build via AssemblyVersion.
/// </summary>
public class AppVersionService
{
/// <summary>
/// Current application version (e.g., "1.0.0.0")
/// </summary>
public string Version { get; }
public AppVersionService()
{
// Get version from assembly metadata
Version = typeof(AppVersionService).Assembly.GetName().Version?.ToString() ?? "1.0.0.0";
}
/// <summary>
/// Generates versioned URL for static assets (cache busting)
/// </summary>
/// <param name="path">Asset path (e.g., "css/envelope-viewer.css")</param>
/// <returns>Versioned URL (e.g., "css/envelope-viewer.css?v=1.0.0.0")</returns>
public string GetVersionedUrl(string path) => $"{path}?v={Version}";
}

View File

@@ -0,0 +1,81 @@
using System.Net;
using System.Net.Http.Json;
using EnvelopeGenerator.WebUI.Client.Options;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.WebUI.Client.Services;
public enum EnvelopeLoginResult { Success, InvalidCode, NotFound, Error }
public enum SenderLoginResult { Success, InvalidCredentials, Error }
public class AuthService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private readonly ApiOptions _api = apiOptions.Value;
/// <summary>
/// Checks whether the current user holds a valid receiver token for the given envelope key.
/// Calls GET /api/auth/check/envelope/{envelopeKey}.
/// </summary>
public async Task<bool> CheckEnvelopeAccessAsync(string envelopeKey, CancellationToken cancel = default)
{
var response = await http.GetAsync($"{_api.BaseUrl}/api/auth/check/envelope/{Uri.EscapeDataString(envelopeKey)}", cancel);
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Submits the access code for the given envelope key.
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
/// On success the API sets an authentication cookie automatically.
/// </summary>
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string envelopeKey, string accessCode, CancellationToken cancel = default)
{
var form = new MultipartFormDataContent();
form.Add(new StringContent(accessCode), "AccessCode");
var response = await http.PostAsync(
$"{_api.BaseUrl}/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}",
form, cancel);
return response.StatusCode switch
{
HttpStatusCode.OK => EnvelopeLoginResult.Success,
HttpStatusCode.Unauthorized => EnvelopeLoginResult.InvalidCode,
HttpStatusCode.NotFound => EnvelopeLoginResult.NotFound,
_ => EnvelopeLoginResult.Error
};
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
/// </summary>
public async Task<bool> LogoutEnvelopeReceiverAsync(string envelopeKey, CancellationToken cancel = default)
{
var response = await http.PostAsync(
$"{_api.BaseUrl}/api/auth/logout/envelope/{Uri.EscapeDataString(envelopeKey)}",
null, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Authenticates a sender user with username and password.
/// Calls POST /api/auth?cookie=true with JSON body.
/// On success the API sets an authentication cookie automatically.
/// </summary>
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password, CancellationToken cancel = default)
{
var requestBody = new { username, password };
var response = await http.PostAsJsonAsync(
$"{_api.BaseUrl}/api/auth?cookie=true",
requestBody, cancel);
return response.StatusCode switch
{
HttpStatusCode.OK => SenderLoginResult.Success,
HttpStatusCode.Unauthorized => SenderLoginResult.InvalidCredentials,
_ => SenderLoginResult.Error
};
}
}

View File

@@ -0,0 +1,40 @@
using DevExpress.DataAccess.Json;
using DevExpress.DataAccess.Web;
using DevExpress.DataAccess.Wizard.Services;
namespace EnvelopeGenerator.WebUI.Client.Services
{
public class CustomDataSourceWizardJsonDataConnectionStorage : IDataSourceWizardJsonConnectionStorage
{
public static JsonDataConnection GetDefaultConnection() {
var uriJsonSource = new UriJsonSource() {
Uri = new Uri(@"https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json"),
};
return new JsonDataConnection(uriJsonSource) { StoreConnectionNameOnly = true, Name = "NWindProductsJson" };
}
public static List<JsonDataConnection> GetConnections() {
var connections = new List<JsonDataConnection> {
GetDefaultConnection()
};
return connections;
}
bool IJsonConnectionStorageService.CanSaveConnection => false;
bool IJsonConnectionStorageService.ContainsConnection(string connectionName) {
return GetConnections().Any(x => x.Name == connectionName);
}
IEnumerable<JsonDataConnection> IJsonConnectionStorageService.GetConnections() {
return GetConnections();
}
JsonDataConnection IJsonDataConnectionProviderService.GetJsonDataConnection(string name) {
var connection = GetConnections().FirstOrDefault(x => x.Name == name);
if(connection == null)
throw new InvalidOperationException();
return connection;
}
void IJsonConnectionStorageService.SaveConnection(string connectionName, JsonDataConnection connection, bool saveCredentials) { }
}
}

View File

@@ -0,0 +1,24 @@
using DevExpress.DataAccess.Json;
using DevExpress.DataAccess.Web;
namespace EnvelopeGenerator.WebUI.Client.Services
{
public class CustomJsonDataConnectionProviderFactory : IJsonDataConnectionProviderFactory {
public IJsonDataConnectionProviderService Create() {
return new WebDocumentViewerJsonDataConnectionProvider(CustomDataSourceWizardJsonDataConnectionStorage.GetConnections());
}
}
public class WebDocumentViewerJsonDataConnectionProvider : IJsonDataConnectionProviderService
{
readonly List<JsonDataConnection> jsonDataConnections;
public WebDocumentViewerJsonDataConnectionProvider(List<JsonDataConnection> jsonDataConnections) {
this.jsonDataConnections = jsonDataConnections;
}
public JsonDataConnection GetJsonDataConnection(string name) {
var connection = jsonDataConnections.FirstOrDefault(x => x.Name == name);
if(connection == null)
throw new InvalidOperationException();
return connection;
}
}
}

View File

@@ -0,0 +1,21 @@
using DevExpress.XtraReports.UI;
using DevExpress.XtraReports.Services;
using EnvelopeGenerator.WebUI.Client.PredefinedReports;
namespace EnvelopeGenerator.WebUI.Client.Services
{
public class CustomReportProvider : IReportProviderAsync {
private readonly InMemoryReportStorageWebExtension reportStorage;
public CustomReportProvider(InMemoryReportStorageWebExtension reportStorage) {
this.reportStorage = reportStorage;
}
public Task<XtraReport> GetReportAsync(string id, ReportProviderContext context) {
if(reportStorage.TryGetReport(id, out var savedReport))
return Task.FromResult(savedReport);
return Task.FromResult(ReportsFactory.GetReport(id));
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Options;
using EnvelopeGenerator.WebUI.Client.Options;
namespace EnvelopeGenerator.WebUI.Client.Services;
public class DocumentService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private readonly ApiOptions _api = apiOptions.Value;
/// <summary>
/// Fetches the PDF bytes for the given envelope key from the API.
/// Throws HttpRequestException on failure with appropriate status code.
/// </summary>
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
public async Task<byte[]?> GetDocumentAsync(string envelopeKey, CancellationToken cancel = default)
{
var response = await http.GetAsync($"{_api.BaseUrl}/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
return bytes;
}
}

View File

@@ -0,0 +1,40 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.WebUI.Client.Models;
using EnvelopeGenerator.WebUI.Client.Options;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.WebUI.Client.Services;
/// <summary>
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
/// from <c>GET api/EnvelopeReceiver/{envelopeKey}</c>.
/// </summary>
public class EnvelopeReceiverService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches the envelope receiver data for the given envelope key from the API.
/// Throws HttpRequestException on failure with appropriate status code.
/// </summary>
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
public async Task<EnvelopeReceiverDto?> GetAsync(string envelopeKey, CancellationToken cancel = default)
{
var url = $"{apiOptions.Value.BaseUrl}/api/EnvelopeReceiver/{Uri.EscapeDataString(envelopeKey)}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load envelope receiver data. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
}
}

View File

@@ -0,0 +1,12 @@
using DevExpress.Drawing;
namespace EnvelopeGenerator.WebUI.Client.Services {
public static class FontLoader {
public async static Task LoadFonts(HttpClient httpClient, List<string> fontNames) {
foreach(var fontName in fontNames) {
var fontBytes = await httpClient.GetByteArrayAsync($"fonts/{fontName}");
DXFontRepository.Instance.AddFont(fontBytes);
}
}
}
}

View File

@@ -0,0 +1,83 @@
using DevExpress.XtraReports.UI;
using DevExpress.XtraReports.Web.Extensions;
using EnvelopeGenerator.WebUI.Client.PredefinedReports;
namespace EnvelopeGenerator.WebUI.Client.Services;
public class InMemoryReportStorageWebExtension : ReportStorageWebExtension
{
private const string DefaultReportName = "LargeDatasetReport";
private static readonly Dictionary<string, byte[]> Reports = new(StringComparer.OrdinalIgnoreCase);
public override bool CanSetData(string url) => IsValidUrl(url);
public override byte[] GetData(string url)
{
url = NormalizeUrl(url);
if (Reports.TryGetValue(url, out var reportLayout))
return reportLayout;
if (ReportsFactory.Reports.TryGetValue(url, out var reportFactory))
return SaveReport(reportFactory());
throw new DevExpress.XtraReports.Web.ClientControls.FaultException($"Report '{url}' was not found.");
}
public override Dictionary<string, string> GetUrls()
{
var urls = ReportsFactory.Reports.Keys
.Concat(Reports.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToDictionary(name => name, name => name, StringComparer.OrdinalIgnoreCase);
return urls;
}
public override bool IsValidUrl(string url)
{
return !string.IsNullOrWhiteSpace(url)
&& url.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
}
public override void SetData(XtraReport report, string url)
{
url = NormalizeUrl(url);
Reports[url] = SaveReport(report);
}
public override string SetNewData(XtraReport report, string defaultUrl)
{
var url = NormalizeUrl(defaultUrl);
Reports[url] = SaveReport(report);
return url;
}
public bool TryGetReport(string url, out XtraReport report)
{
url = NormalizeUrl(url);
if (!Reports.ContainsKey(url))
{
report = null!;
return false;
}
using var stream = new MemoryStream(Reports[url]);
report = XtraReport.FromXmlStream(stream, true);
report.Name = url;
return true;
}
private static string NormalizeUrl(string url)
{
return string.IsNullOrWhiteSpace(url) ? DefaultReportName : url;
}
private static byte[] SaveReport(XtraReport report)
{
using var stream = new MemoryStream();
report.SaveLayoutToXml(stream);
return stream.ToArray();
}
}

View File

@@ -0,0 +1,9 @@
using DevExpress.DataAccess.Web;
namespace EnvelopeGenerator.WebUI.Client.Services {
public class ObjectDataSourceWizardCustomTypeProvider : IObjectDataSourceWizardTypeProvider {
public IEnumerable<Type> GetAvailableTypes(string context) {
return new[] { typeof(Data.DataItemList) };
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
using EnvelopeGenerator.WebUI.Client.Options;
using EnvelopeGenerator.WebUI.Client.Models;
namespace EnvelopeGenerator.WebUI.Client.Services;
/// <summary>
/// Client service for managing cached signatures via API.
/// </summary>
public class SignatureCacheService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private readonly ApiOptions _api = apiOptions.Value;
public async Task SaveSignatureAsync(
string envelopeKey,
SignatureCaptureDto signature,
CancellationToken cancel = default)
{
var response = await http.PostAsJsonAsync(
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
signature,
cancel);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException($"Failed to cache signature: {response.StatusCode} - {error}");
}
}
public async Task<SignatureCaptureDto?> GetSignatureAsync(
string envelopeKey,
CancellationToken cancel = default)
{
var response = await http.GetAsync(
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
cancel);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException($"Failed to retrieve signature: {response.StatusCode} - {error}");
}
return await response.Content.ReadFromJsonAsync<SignatureCaptureDto>(cancellationToken: cancel);
}
public async Task DeleteSignatureAsync(
string envelopeKey,
CancellationToken cancel = default)
{
var response = await http.DeleteAsync(
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
cancel);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException($"Failed to delete signature: {response.StatusCode} - {error}");
}
}
}

View File

@@ -0,0 +1,24 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.WebUI.Client.Models;
using EnvelopeGenerator.WebUI.Client.Options;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.WebUI.Client.Services;
public class SignatureService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
{
var url = $"{apiOptions.Value.BaseUrl}/api/Signature/{Uri.EscapeDataString(envelopeKey)}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to retrieve signatures for envelope {envelopeKey}: {response.StatusCode} {response.ReasonPhrase}");
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? [];
}
}