diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/AnnotationDto.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/AnnotationDto.cs new file mode 100644 index 00000000..2a27a2ea --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/AnnotationDto.cs @@ -0,0 +1,32 @@ +namespace EnvelopeGenerator.WebUI.Client.Models; + +/// +/// Represents a pre-assigned signature annotation position on a specific page. +///

+/// Coordinate unit (X, Y): Inches (GdPicture14 native unit), +/// origin at the top-left corner of the page, both axes increase downward/rightward. +///

+/// Conversion to DevExpress: Multiply by 100 (DX uses 1/100 inch). +/// Convert: xDX = xInches * 100.0 +///
+/// Conversion to PDF Points: Multiply by 72 (1 inch = 72 points). +/// Convert: xPt = xInches * 72.0 +///
+/// Y-axis for PDF (bottom-left origin): Flip required for iText7. +/// Convert: yPt = (pageHeightInches - yInches - elemHeightInches) * 72.0 +///
+[Obsolete("Use SignatureDto with SignatureService.")] +public record AnnotationDto +{ + /// Unique identifier of the annotation. + public long Id { get; init; } + + /// 1-based page number within the document. + public int Page { get; init; } + + /// Horizontal position in INCHES from the left edge of the page. + public double X { get; init; } + + /// Vertical position in INCHES from the top edge of the page. + public double Y { get; init; } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/SenderAppType.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/SenderAppType.cs new file mode 100644 index 00000000..f8d0a2b9 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/SenderAppType.cs @@ -0,0 +1,8 @@ +namespace EnvelopeGenerator.WebUI.Client.Models.Constants +{ + public enum SenderAppType + { + LegacyFormApp = 0, + ReceiverUIBlazorApp = 1 + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/UnitOfLength.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/UnitOfLength.cs new file mode 100644 index 00000000..ada84932 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/Constants/UnitOfLength.cs @@ -0,0 +1,65 @@ +namespace EnvelopeGenerator.WebUI.Client.Models.Constants; + +/// +/// Represents the unit of measurement for coordinate values in signature positioning. +/// Used for converting coordinates between different systems (GdPicture14, PDF.js, iText7). +/// +public enum UnitOfLength +{ + /// + /// 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. + /// + /// + /// Source: GdPicture14.Annotations.AnnotationStickyNote uses INCHES natively. + ///
+ /// Evidence: VB.NET code directly assigns database values to annotation properties without conversion: + /// + /// oAnnotation.Left = CSng(pElement.X) ' Direct assignment ? INCHES + /// oAnnotation.Top = CSng(pElement.Y) + /// + /// Standard Page Dimensions: + /// + /// A4: 8.27" × 11.69" (210mm × 297mm) + /// Letter: 8.5" × 11" + /// + ///
+ Inch = 0, + + /// + /// PDF Point unit (1 point = 1/72 inch). + /// This is the standard unit used by PDF specification and PDF.js viewer. + /// + /// + /// Definition: According to PDF specification and Microsoft documentation: + ///
+ /// "PDF pages are sized in point units. 1 pt == 1/72 inch" + ///

+ /// Conversion Formula: + /// + /// points = inches * 72.0 + /// inches = points / 72.0 + /// + /// Important: Point ? Pixel! + /// + /// Point (pt): Device-independent unit (always 1/72 inch) + /// Pixel (px): Device-dependent unit (varies with screen DPI) + /// At 72 DPI: 1 point = 1 pixel (coincidence) + /// At 96 DPI: 1 point ? 1.33 pixels + /// At 300 DPI: 1 point ? 4.17 pixels + /// + /// Standard Page Dimensions (in points): + /// + /// A4: 595 × 842 points (8.27" × 11.69" × 72) + /// Letter: 612 × 792 points (8.5" × 11" × 72) + /// + /// Usage in EnvelopeGenerator: + /// + /// PDF.js viewer expects coordinates in points + /// iText7 library uses points for PDF manipulation + /// PSPDFKit (Web) uses points for annotation placement + /// + ///
+ Point +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/EnvelopeReceiverDto.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/EnvelopeReceiverDto.cs new file mode 100644 index 00000000..718b9572 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/EnvelopeReceiverDto.cs @@ -0,0 +1,105 @@ +namespace EnvelopeGenerator.WebUI.Client.Models; + +/// +/// Client-side model for the envelope receiver returned by +/// GET api/EnvelopeReceiver/{envelopeKey}. +/// +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; } +} + +/// +/// Client-side model for the envelope data embedded in . +/// +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? Documents { get; init; } + public EnvelopeSenderDto? User { get; init; } +} + +/// +/// Sender (user) information embedded in . +/// +public record EnvelopeSenderDto +{ + public int Id { get; init; } + public string? Username { get; init; } + public string? FullName { get; init; } + public string? Email { get; init; } +} + +/// +/// Client-side model for a document embedded in . +/// +public record DocumentClientDto +{ + public int Id { get; init; } + public int EnvelopeId { get; init; } + public DateTime AddedWhen { get; init; } + public IEnumerable? Elements { get; init; } +} + +/// +/// Client-side model for a signature/annotation element embedded in . +/// +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; } +} + +/// +/// Client-side model for the receiver data embedded in . +/// +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; } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureCaptureDto.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureCaptureDto.cs new file mode 100644 index 00000000..6e528b06 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureCaptureDto.cs @@ -0,0 +1,61 @@ +namespace EnvelopeGenerator.WebUI.Client.Models; + +/// +/// 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. +/// +/// +/// Used in: EnvelopeViewer.razor signature popup workflow +///
+/// Creation: User draws/types/uploads signature and fills required fields +///
+/// Storage: Session-only (Blazor component state, lost on page refresh) +///
+/// Rendering: Applied signatures display: Image + Separator + Name/Position/Place/Date +///
+public sealed record SignatureCaptureDto +{ + /// + /// Base64-encoded data URL of the signature image. + ///
+ /// Format: data:image/png;base64,iVBORw0KG... + ///
+ /// Source: Canvas.toDataURL() from signature pad (draw/text/image tabs) + ///
+ /// Usage: Set as img.src in applied signature overlay + ///
+ public required string DataUrl { get; init; } + + /// + /// Full name of the signer (first and last name). + ///
+ /// Required: Yes (validated in popup) + /// Display: Bold text in applied signature block + ///
+ /// Example: "Max Mustermann" + ///
+ public required string FullName { get; init; } + + /// + /// Job title or position of the signer. + ///
+ /// Required: No (optional field) + ///
+ /// Display: Normal weight text between name and place/date + ///
+ /// Example: "Geschäftsführer" or empty string + ///
+ public string Position { get; init; } = string.Empty; + + /// + /// Location/place where the signature was created. + ///
+ /// Required: Yes (validated in popup) + ///
+ /// Display: Shown with current date in German format (dd.MM.yyyy) + ///
+ /// Example: "Berlin" ? rendered as "Berlin, 26.01.2025" + ///
+ public required string Place { get; init; } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureDto.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureDto.cs new file mode 100644 index 00000000..cb27642d --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Models/SignatureDto.cs @@ -0,0 +1,101 @@ +using EnvelopeGenerator.WebUI.Client.Models.Constants; + +namespace EnvelopeGenerator.WebUI.Client.Models; + +/// +/// 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. +/// +public class SignatureDto +{ + /// Unique identifier. + public int Id { get; init; } + + private double _x; + private double _y; + + /// Horizontal position in INCHES from left edge. + public double X + { + get => _x * Factor; + init => _x = value; + } + + /// Vertical position in INCHES from top edge. + public double Y + { + get => _y * Factor; + init => _y = value; + } + + /// 1-based page number. + public int Page { get; init; } + + /// Sender application type that created this signature. + 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 +{ + /// + /// Converts all signatures in the collection to the specified unit of length. + /// + /// Type of the collection (IEnumerable, List, etc.) + /// Collection of SignatureDto objects to convert. + /// Target unit of measurement (Inch or Point). + /// The same collection with all signatures converted to the specified unit. + /// Thrown when signatures collection is null. + /// + /// Usage: + /// + /// var signatures = await SignatureService.GetAsync(envelopeKey); + /// var convertedSignatures = signatures.ConvertAll(UnitOfLength.Point); + /// + /// Note: This method modifies each SignatureDto object in place and returns the same collection. + /// + public static T Convert(this T signatures, UnitOfLength unitOfLength) + where T : IEnumerable + { + if (signatures == null) + throw new ArgumentNullException(nameof(signatures)); + + foreach (var signature in signatures) + { + signature.Convert(unitOfLength); + } + + return signatures; + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/ApiOptions.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/ApiOptions.cs new file mode 100644 index 00000000..cbed99b9 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/ApiOptions.cs @@ -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; +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/PdfViewerOptions.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/PdfViewerOptions.cs new file mode 100644 index 00000000..421a001b --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Options/PdfViewerOptions.cs @@ -0,0 +1,71 @@ +namespace EnvelopeGenerator.WebUI.Client.Options; + +public class PdfViewerOptions +{ + public const string SectionName = "PdfViewerOptions"; + + /// + /// Base scale for thumbnail rendering (0.2 - 1.5 recommended) + /// Higher values = better quality but slower rendering + /// Default: 0.75 + /// + public double ThumbnailBaseScale { get; set; } = 0.75; + + /// + /// Enable HiDPI/Retina support for thumbnails + /// Default: true + /// + public bool ThumbnailEnableHiDPI { get; set; } = true; + + /// + /// Maximum device pixel ratio multiplier for thumbnails (1.0 - 3.0) + /// Caps DPR to avoid excessive memory usage on 4K+ displays + /// Default: 2.0 + /// + public double ThumbnailMaxDPR { get; set; } = 2.0; + + /// + /// Enable HiDPI/Retina support for main PDF canvas + /// Default: true + /// + public bool MainCanvasEnableHiDPI { get; set; } = true; + + /// + /// Maximum device pixel ratio multiplier for main canvas (1.0 - 3.0) + /// Default: 2.0 + /// + public double MainCanvasMaxDPR { get; set; } = 2.0; + + /// + /// Enable smooth zoom transition (fade effect) + /// Default: true + /// + public bool EnableSmoothZoom { get; set; } = true; + + /// + /// Zoom transition duration in milliseconds (50 - 500) + /// Default: 150 + /// + public int ZoomTransitionDuration { get; set; } = 150; + + /// + /// Opacity during rendering (0.0 - 1.0) + /// Lower values = more visible fade effect + /// Default: 0.85 + /// + public double RenderingOpacity { get; set; } = 0.85; + + /// + /// Delay between thumbnail renders in milliseconds (10 - 200) + /// Higher values = less browser stress, slower initial load + /// Default: 50 + /// + public int ThumbnailRenderDelay { get; set; } = 50; + + /// + /// Zoom step percentage (1 - 50) + /// Controls how much zoom changes per click or scroll + /// Default: 5 (5% per step) + /// + public int ZoomStepPercentage { get; set; } = 5; +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AnnotationService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AnnotationService.cs new file mode 100644 index 00000000..b70e9db2 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AnnotationService.cs @@ -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; + +/// +/// Retrieves annotation positions from the API. +/// The URL is composed as {BaseUrl}/api/Annotation/{envelopeKey}. +/// During development, BaseUrl is empty so the request resolves to the +/// YARP-proxied route on the same origin, which currently serves +/// fake-data/annotations.json. To switch to real data, update the +/// YARP route in yarp.json – no code change required. +/// +[Obsolete("Use SignatureService.")] +public class AnnotationService(HttpClient http, IOptions apiOptions) +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> 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>(_jsonOptions, cancel); + return result ?? []; + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AppVersionService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AppVersionService.cs new file mode 100644 index 00000000..7d18243d --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AppVersionService.cs @@ -0,0 +1,26 @@ +namespace EnvelopeGenerator.WebUI.Client.Services; + +/// +/// Provides application version for cache busting static assets. +/// Version is automatically incremented on each build via AssemblyVersion. +/// +public class AppVersionService +{ + /// + /// Current application version (e.g., "1.0.0.0") + /// + public string Version { get; } + + public AppVersionService() + { + // Get version from assembly metadata + Version = typeof(AppVersionService).Assembly.GetName().Version?.ToString() ?? "1.0.0.0"; + } + + /// + /// Generates versioned URL for static assets (cache busting) + /// + /// Asset path (e.g., "css/envelope-viewer.css") + /// Versioned URL (e.g., "css/envelope-viewer.css?v=1.0.0.0") + public string GetVersionedUrl(string path) => $"{path}?v={Version}"; +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AuthService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AuthService.cs new file mode 100644 index 00000000..6e1af9e6 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/AuthService.cs @@ -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) +{ + private readonly ApiOptions _api = apiOptions.Value; + + /// + /// Checks whether the current user holds a valid receiver token for the given envelope key. + /// Calls GET /api/auth/check/envelope/{envelopeKey}. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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 + }; + } + + /// + /// Removes the per-envelope receiver cookie for the given envelope key. + /// Calls POST /api/auth/logout/envelope/{envelopeKey}. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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 + }; + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomDataSourceWizardJsonDataConnectionStorage.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomDataSourceWizardJsonDataConnectionStorage.cs new file mode 100644 index 00000000..8812b0c1 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomDataSourceWizardJsonDataConnectionStorage.cs @@ -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 GetConnections() { + var connections = new List { + GetDefaultConnection() + }; + return connections; + } + + bool IJsonConnectionStorageService.CanSaveConnection => false; + bool IJsonConnectionStorageService.ContainsConnection(string connectionName) { + return GetConnections().Any(x => x.Name == connectionName); + } + + IEnumerable 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) { } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomJsonDataConnectionProviderFactory.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomJsonDataConnectionProviderFactory.cs new file mode 100644 index 00000000..bbe9be74 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomJsonDataConnectionProviderFactory.cs @@ -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 jsonDataConnections; + public WebDocumentViewerJsonDataConnectionProvider(List 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; + } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomReportProvider.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomReportProvider.cs new file mode 100644 index 00000000..a32769e6 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/CustomReportProvider.cs @@ -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 GetReportAsync(string id, ReportProviderContext context) { + if(reportStorage.TryGetReport(id, out var savedReport)) + return Task.FromResult(savedReport); + + return Task.FromResult(ReportsFactory.GetReport(id)); + } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/DocumentService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/DocumentService.cs new file mode 100644 index 00000000..b9cf4456 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/DocumentService.cs @@ -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) +{ + private readonly ApiOptions _api = apiOptions.Value; + + /// + /// Fetches the PDF bytes for the given envelope key from the API. + /// Throws HttpRequestException on failure with appropriate status code. + /// + /// Thrown when the API request fails. + public async Task 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; + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/EnvelopeReceiverService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/EnvelopeReceiverService.cs new file mode 100644 index 00000000..0abfc7a9 --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/EnvelopeReceiverService.cs @@ -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; + +/// +/// Retrieves the for the authenticated receiver +/// from GET api/EnvelopeReceiver/{envelopeKey}. +/// +public class EnvelopeReceiverService(HttpClient http, IOptions apiOptions) +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Fetches the envelope receiver data for the given envelope key from the API. + /// Throws HttpRequestException on failure with appropriate status code. + /// + /// Thrown when the API request fails. + public async Task 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(_jsonOptions, cancel); + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/FontLoader.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/FontLoader.cs new file mode 100644 index 00000000..aa48b9bb --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/FontLoader.cs @@ -0,0 +1,12 @@ +using DevExpress.Drawing; + +namespace EnvelopeGenerator.WebUI.Client.Services { + public static class FontLoader { + public async static Task LoadFonts(HttpClient httpClient, List fontNames) { + foreach(var fontName in fontNames) { + var fontBytes = await httpClient.GetByteArrayAsync($"fonts/{fontName}"); + DXFontRepository.Instance.AddFont(fontBytes); + } + } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/InMemoryReportStorageWebExtension.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/InMemoryReportStorageWebExtension.cs new file mode 100644 index 00000000..c782432f --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/InMemoryReportStorageWebExtension.cs @@ -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 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 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(); + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/ObjectDataSourceWizardCustomTypeProvider.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/ObjectDataSourceWizardCustomTypeProvider.cs new file mode 100644 index 00000000..4aba423d --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/ObjectDataSourceWizardCustomTypeProvider.cs @@ -0,0 +1,9 @@ +using DevExpress.DataAccess.Web; + +namespace EnvelopeGenerator.WebUI.Client.Services { + public class ObjectDataSourceWizardCustomTypeProvider : IObjectDataSourceWizardTypeProvider { + public IEnumerable GetAvailableTypes(string context) { + return new[] { typeof(Data.DataItemList) }; + } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureCacheService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureCacheService.cs new file mode 100644 index 00000000..1421ae9c --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureCacheService.cs @@ -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; + +/// +/// Client service for managing cached signatures via API. +/// +public class SignatureCacheService(HttpClient http, IOptions 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 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(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}"); + } + } +} diff --git a/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureService.cs b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureService.cs new file mode 100644 index 00000000..0ee94bba --- /dev/null +++ b/EnvelopeGenerator.WebUI/EnvelopeGenerator.WebUI.Client/Services/SignatureService.cs @@ -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) +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> 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>(_jsonOptions, cancel); + return result ?? []; + } +}