Compare commits
31 Commits
feat/serve
...
feat/wizar
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d2225a44 | |||
| 595bbf3f25 | |||
| ee7d01ed7f | |||
| 85104511fd | |||
| 1b87528cca | |||
| 2913514da8 | |||
| ce7e70162b | |||
| 5e00b6e9ec | |||
| f0b4c8d00c | |||
| abf616c115 | |||
| 42585e1f2c | |||
| ff10fc6b0a | |||
| 7222f7e9a7 | |||
| d913b949e0 | |||
| a35e05d016 | |||
| 8aabc3e233 | |||
| 8b7f99eb76 | |||
| 91e90b322b | |||
| 74e55e9206 | |||
| fc5ad7ea3c | |||
| ff5582c9b5 | |||
| 6523cca945 | |||
| 28d0b6f81c | |||
| 5df38754cb | |||
| c2b7579717 | |||
| ae1ecbc15f | |||
| 4dcfe67038 | |||
| c812d4dc1f | |||
| 8d27718062 | |||
| c746612d80 | |||
| 6dfcc1b2be |
@@ -0,0 +1,17 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class SignatureFieldParams
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public double Width { get; init; } = 1.77;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public double Height { get; init; } = 1.96;
|
||||
}
|
||||
@@ -23,5 +23,5 @@ public record DocumentDto : DocumentDetailsDto
|
||||
/// <summary>
|
||||
/// Gets or sets the binary data of the document, if available.
|
||||
/// </summary>
|
||||
public byte[]? ByteData { get; init; }
|
||||
public byte[]? ByteData { get; set; }
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public record EnvelopeDto : IEnvelope
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value is 'de-DE'
|
||||
@@ -84,7 +84,7 @@ public record EnvelopeDto : IEnvelope
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing a type of envelope with its configuration settings.
|
||||
@@ -28,7 +30,7 @@ public class EnvelopeTypeDto
|
||||
/// <summary>
|
||||
/// Gets or sets the certification type identifier.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an access code is required.
|
||||
@@ -78,5 +80,5 @@ public class EnvelopeTypeDto
|
||||
/// <summary>
|
||||
/// Gets or sets the contract type associated with the envelope type.
|
||||
/// </summary>
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public interface IRequestContext
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
string? Origin { get; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record DocumentReceivedNotification : EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string DocumentTitle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string NameReceiver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string NameSender { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// [SIGNATURE_TYPE]
|
||||
/// </summary>
|
||||
public required bool IsReadAndConfirm { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string SignatureType => IsReadAndConfirm ? "Signieren" : "Lesen und bestätigen";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required bool AdvancedSignature { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string DocumentProcess => AdvancedSignature ? " und elektronisch unterschreiben" : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required Uri Origin { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string EnvelopeUuid { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string ReceiverSignature { private get; init; }
|
||||
|
||||
private Uri EnvelopeURL => new(Origin, $"Envelope/{(EnvelopeUuid, ReceiverSignature).ToEnvelopeKey()}");
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string LinkToDocument => EnvelopeURL.AbsoluteUri;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string LinkToDocumentText
|
||||
{
|
||||
get
|
||||
{
|
||||
var urlText = EnvelopeURL.ToString();
|
||||
return $"{urlText[..Math.Min(urlText.Length, 40)]}..";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string NamePortal { get; set; } = "signFLOW Portal";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public override EmailTemplateType GetTemplateType()
|
||||
{
|
||||
return EmailTemplateType.DocumentReceived;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record DocumentSignedNotification
|
||||
public record DocumentSignedNotification : EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
@@ -23,7 +25,27 @@ public record DocumentSignedNotification
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required byte[] Attachment { get; init; }
|
||||
public required bool IsReadAndConfirm { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string ReceiverAction => IsReadAndConfirm ? "bestätigt" : "unterschrieben";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string FinalStatus => IsReadAndConfirm ? "Lesebestätigung" : "Signatur";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string FinalAction => IsReadAndConfirm ? "Empfänger bestätigt" : "Vertragspartner unterzeichnet";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public override EmailTemplateType GetTemplateType() => EmailTemplateType.DocumentSigned;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public abstract record EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public abstract EmailTemplateType GetTemplateType();
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string FileName, byte[] Content)>? Attachments { get; init; }
|
||||
}
|
||||
@@ -8,9 +8,11 @@ public interface IEnvelopeNotificationService
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <param name="documentSigned"></param>
|
||||
/// <typeparam name="TEnvelopeNotificationBase"></typeparam>
|
||||
/// <param name="receiverEmail"></param>
|
||||
/// <param name="notification"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
public Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default);
|
||||
public Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
|
||||
where TEnvelopeNotificationBase : EnvelopeNotificationBase;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
@@ -96,6 +97,10 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public virtual async Task Handle(TNotification notification, CancellationToken cancel)
|
||||
{
|
||||
// do not send email if the envelope is deleted and the status is less than or equal to EnvelopeSaved
|
||||
if (notification is EnvelopeDeletedNotification delNot && delNot.Envelope.Status <= EnvelopeStatus.EnvelopeSaved)
|
||||
return;
|
||||
|
||||
var placeHolders = CreatePlaceHolders(notification);
|
||||
|
||||
var temp = await TempRepo
|
||||
|
||||
@@ -49,6 +49,7 @@ public static class DependencyInjection
|
||||
|
||||
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
|
||||
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
|
||||
services.Configure<SignatureFieldParams>(config.GetSection(nameof(SignatureFieldParams)));
|
||||
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
|
||||
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
||||
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
@@ -21,12 +19,10 @@ public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRe
|
||||
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
|
||||
{
|
||||
private readonly IRepository<Document> _repo;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo)
|
||||
{
|
||||
_repo = repo;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
|
||||
@@ -36,9 +32,71 @@ public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetai
|
||||
if (query.UserId is int userId)
|
||||
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
|
||||
|
||||
return await docQuery
|
||||
// shortcut: manual select avoids AutoMapper ProjectTo pulling BYTE_DATA and the full navigation
|
||||
// graph (Envelope → History → User etc.) which caused an extremely slow query (~87s).
|
||||
// Annotations navigation is IEnumerable<T> on the entity (not ICollection<T>), so EF Core
|
||||
// cannot translate it in a Select projection — we skip it entirely here; sender editor only
|
||||
// needs element coordinates, not annotation payloads.
|
||||
var raw = await docQuery
|
||||
.Where(d => d.EnvelopeId == query.EnvelopeId)
|
||||
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
|
||||
.Select(d => new
|
||||
{
|
||||
d.Id,
|
||||
d.EnvelopeId,
|
||||
d.AddedWhen,
|
||||
Elements = d.Elements == null ? null : d.Elements.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.DocumentId,
|
||||
e.ReceiverId,
|
||||
e.ElementType,
|
||||
e.X,
|
||||
e.Y,
|
||||
e.Width,
|
||||
e.Height,
|
||||
e.Page,
|
||||
e.Required,
|
||||
e.Tooltip,
|
||||
e.ReadOnly,
|
||||
e.AnnotationIndex,
|
||||
e.AddedWhen,
|
||||
e.ChangedWhen,
|
||||
e.FullName,
|
||||
e.Position,
|
||||
e.Place,
|
||||
}).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancel);
|
||||
|
||||
if (raw is null)
|
||||
return null;
|
||||
|
||||
return new DocumentDetailsDto
|
||||
{
|
||||
Id = raw.Id,
|
||||
EnvelopeId = raw.EnvelopeId,
|
||||
AddedWhen = raw.AddedWhen,
|
||||
Elements = raw.Elements?.Select(e => new DocReceiverElementDto
|
||||
{
|
||||
Id = e.Id,
|
||||
DocumentId = e.DocumentId,
|
||||
ReceiverId = e.ReceiverId,
|
||||
ElementType = e.ElementType,
|
||||
X = e.X,
|
||||
Y = e.Y,
|
||||
Width = e.Width,
|
||||
Height = e.Height,
|
||||
Page = e.Page,
|
||||
Required = e.Required,
|
||||
Tooltip = e.Tooltip,
|
||||
ReadOnly = e.ReadOnly,
|
||||
AnnotationIndex = e.AnnotationIndex,
|
||||
AddedWhen = e.AddedWhen,
|
||||
ChangedWhen = e.ChangedWhen,
|
||||
FullName = e.FullName,
|
||||
Position = e.Position,
|
||||
Place = e.Place,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record RemoveEnvelopeReceiverCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required int EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required int ReceiverId { get; init; }
|
||||
|
||||
internal int? UserId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public RemoveEnvelopeReceiverCommand Authenticated(int userId)
|
||||
{
|
||||
UserId = userId;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
public class RemoveEnvelopeReceiverCommandHandler(
|
||||
IRepository<EnvelopeReceiver> Repo,
|
||||
IRepository<DocReceiverElement> ElementRepo,
|
||||
IRepository<ElementAnnotation> AnnotRepo) : IRequestHandler<RemoveEnvelopeReceiverCommand>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public async Task Handle(RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||
{
|
||||
var er = await Repo.Query
|
||||
.Where(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId)
|
||||
.Include(er => er.Envelope)
|
||||
.SingleOrDefaultAsync(cancel)
|
||||
?? throw new NotFoundException();
|
||||
|
||||
if (er.Envelope is null)
|
||||
throw new InvalidOperationException($"In {nameof(RemoveEnvelopeReceiverCommandHandler)} EnvelopeReceiver.Envelope is null." +
|
||||
$"This should not happen." +
|
||||
$"EnvelopeId: {request.EnvelopeId}, ReceiverId: {request.ReceiverId}");
|
||||
|
||||
if (request.UserId is int userId && er.Envelope.UserId != userId)
|
||||
throw new AuthorizationException("Cannot remove receiver from envelope that you do not own.");
|
||||
|
||||
if (er.Envelope.Status > EnvelopeStatus.EnvelopeSaved)
|
||||
throw new ForbiddenException("Cannot remove receiver from envelope that is already processed.");
|
||||
|
||||
// 1. Delete all ElementAnnotations for this EnvelopeReceiver
|
||||
await AnnotRepo.DeleteAsync(ann => ann.Element.Document.EnvelopeId == request.EnvelopeId && ann.Element.ReceiverId == request.ReceiverId, cancel);
|
||||
|
||||
// 2. Delete all DocReceiverElements for this EnvelopeReceiver
|
||||
await ElementRepo.DeleteAsync(el => el.Document.EnvelopeId == request.EnvelopeId && el.ReceiverId == request.ReceiverId, cancel);
|
||||
|
||||
// 3. Delete the EnvelopeReceiver
|
||||
await Repo.DeleteAsync(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId, cancel);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
|
||||
Title = t.Title ?? string.Empty,
|
||||
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
|
||||
ExpiresDays = t.ExpiresDays,
|
||||
CertificationType = (int?)t.CertificationType,
|
||||
CertificationType = t.CertificationType,
|
||||
UseAccessCode = t.UseAccessCode,
|
||||
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@@ -35,7 +36,7 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
/// <summary>
|
||||
/// Zertifizierungstyp fuer den Signaturprozess.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer den Ersteller.
|
||||
@@ -129,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
public bool UseSQLExecutor { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="DataAsBase64"></param>
|
||||
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="X"></param>
|
||||
/// <param name="Y"></param>
|
||||
/// <param name="Page"></param>
|
||||
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="EmailAddress"></param>
|
||||
/// <param name="Name"></param>
|
||||
/// <param name="PhoneNumber"></param>
|
||||
/// <param name="Fields"></param>
|
||||
public record CreateEnvelopeReceiverDto(
|
||||
string EmailAddress,
|
||||
string? Name,
|
||||
|
||||
@@ -1,68 +1,66 @@
|
||||
using AutoMapper;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using DigitalData.UserManager.Domain.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Application.Histories.Commands;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
||||
/// <param name="Provider"></param>
|
||||
/// <param name="Mapper"></param>
|
||||
/// <param name="EnvNtfService"></param>
|
||||
/// <param name="ReqCntxt"></param>
|
||||
/// <param name="Logger"></param>
|
||||
public class CreateEnvelopeCommandHandler(IServiceProvider Provider, IMapper Mapper, IEnvelopeNotificationService EnvNtfService, IRequestContext ReqCntxt, ILogger<CreateEnvelopeCommandHandler> Logger) : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
||||
{
|
||||
private readonly IServiceProvider _provider;
|
||||
private IEnvelopeExecutor Executor => Provider.GetRequiredService<IEnvelopeExecutor>();
|
||||
|
||||
private IEnvelopeExecutor Executor => _provider.GetRequiredService<IEnvelopeExecutor>();
|
||||
private IRepository<Envelope> Repository => Provider.GetRequiredService<IRepository<Envelope>>();
|
||||
|
||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
||||
private IRepository<Document> DocumentRepository => Provider.GetRequiredService<IRepository<Document>>();
|
||||
|
||||
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => Provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => Provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
private IRepository<Receiver> ReceiverRepository => Provider.GetRequiredService<IRepository<Receiver>>();
|
||||
|
||||
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => Provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
private IRepository<EmailTemplate> EmailTemplateRepository => Provider.GetRequiredService<IRepository<EmailTemplate>>();
|
||||
|
||||
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
|
||||
private IRepository<EmailOut> EmailOutRepository => Provider.GetRequiredService<IRepository<EmailOut>>();
|
||||
|
||||
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
|
||||
private MailParams MailParams => Provider.GetRequiredService<IOptions<MailParams>>().Value;
|
||||
|
||||
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
|
||||
private DispatcherParams DispatcherParams => Provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
|
||||
|
||||
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
|
||||
private SignatureFieldParams SignatureFieldParams => Provider.GetRequiredService<IOptionsMonitor<SignatureFieldParams>>().CurrentValue;
|
||||
|
||||
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
|
||||
[Obsolete("Use IConfigService instead.")]
|
||||
private IConfigService ConfigService => Provider.GetRequiredService<IConfigService>();
|
||||
|
||||
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
||||
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="provider"></param>
|
||||
/// <param name="mapper"></param>
|
||||
public CreateEnvelopeCommandHandler(IServiceProvider provider, IMapper mapper)
|
||||
{
|
||||
_provider = provider;
|
||||
_mapper = mapper;
|
||||
}
|
||||
private IMediator Mediator => Provider.GetRequiredService<IMediator>();
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
@@ -230,7 +228,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
.ThenInclude(er => er.Receiver)
|
||||
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
||||
|
||||
return _mapper.Map<EnvelopeDto>(envelope);
|
||||
return Mapper.Map<EnvelopeDto>(envelope);
|
||||
}
|
||||
|
||||
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
|
||||
@@ -268,9 +266,9 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
|
||||
UseAccessCode: useAccessCode,
|
||||
CertificationType: request.CertificationType
|
||||
?? (int?)selectedType?.CertificationType
|
||||
?? selectedType?.CertificationType
|
||||
?? existing?.CertificationType
|
||||
?? (int)CertificationType.AdvancedElectronicSignature,
|
||||
?? CertificationType.AdvancedElectronicSignature,
|
||||
FinalEmailToCreator: request.FinalEmailToCreator
|
||||
?? (int?)selectedType?.FinalEmailToCreator
|
||||
?? existing?.FinalEmailToCreator
|
||||
@@ -299,7 +297,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
|
||||
ContractType: existing?.ContractType
|
||||
?? selectedType?.ContractType
|
||||
?? (int)ContractType.Contract);
|
||||
?? ContractType.Contract);
|
||||
}
|
||||
|
||||
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
|
||||
@@ -320,7 +318,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
int CertificationType,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
@@ -330,7 +328,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
int ExpiresWarningWhenDays,
|
||||
DateTime? ExpiresWhen,
|
||||
DateTime? ExpiresWarningWhen,
|
||||
int ContractType);
|
||||
ContractType ContractType);
|
||||
|
||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||
{
|
||||
@@ -444,8 +442,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
ElementType = 1,
|
||||
X = field.X,
|
||||
Y = field.Y,
|
||||
Width = 1.77,
|
||||
Height = 1.96,
|
||||
Width = SignatureFieldParams.Width,
|
||||
Height = SignatureFieldParams.Height,
|
||||
Page = field.Page,
|
||||
Required = true,
|
||||
ReadOnly = false,
|
||||
@@ -529,15 +527,15 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
if (!hasDocument)
|
||||
throw new BadRequestException("Cannot send envelope without a document.");
|
||||
|
||||
var links = await EnvelopeReceiverRepository.Query
|
||||
var envRcvs = await EnvelopeReceiverRepository.Query
|
||||
.Include(er => er.Receiver)
|
||||
.Where(er => er.EnvelopeId == envelope.Id)
|
||||
.ToListAsync(cancel);
|
||||
|
||||
if (links.Count == 0)
|
||||
if (envRcvs.Count == 0)
|
||||
throw new BadRequestException("Cannot send envelope without receivers.");
|
||||
|
||||
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
||||
var receiverIds = envRcvs.Select(l => l.ReceiverId).Distinct().ToList();
|
||||
var documentIds = await DocumentRepository.Query
|
||||
.Where(d => d.EnvelopeId == envelope.Id)
|
||||
.Select(d => d.Id)
|
||||
@@ -550,7 +548,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
||||
|
||||
var receiversWithoutFields = links
|
||||
var receiversWithoutFields = envRcvs
|
||||
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
||||
.Select(link => link.Name)
|
||||
.ToList();
|
||||
@@ -574,70 +572,39 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
Status = EnvelopeStatus.EnvelopeQueued
|
||||
}, cancel);
|
||||
|
||||
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
|
||||
await SendInvitationEmailsAsync(envelope, envRcvs, cancel);
|
||||
|
||||
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||
}
|
||||
|
||||
private async Task QueueInvitationEmailsAsync(
|
||||
private async Task SendInvitationEmailsAsync(
|
||||
Envelope envelope,
|
||||
IReadOnlyCollection<EnvelopeReceiver> links,
|
||||
string senderReference,
|
||||
IReadOnlyCollection<EnvelopeReceiver> envRcvs,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
var template = await EmailTemplateRepository.Query
|
||||
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
|
||||
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
|
||||
|
||||
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
|
||||
var senderName = senderReference;
|
||||
|
||||
foreach (var link in links)
|
||||
foreach (var er in envRcvs)
|
||||
{
|
||||
var receiver = link.Receiver;
|
||||
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
|
||||
continue;
|
||||
|
||||
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
|
||||
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
|
||||
|
||||
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
|
||||
var advancedSignature = envelope.CertificationType == CertificationType.AdvancedElectronicSignature;
|
||||
var origin = new Uri(ReqCntxt.Origin ?? throw new InvalidOperationException($"The request origin is missing in {nameof(CreateEnvelopeCommandHandler)}."));
|
||||
try
|
||||
{
|
||||
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
|
||||
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
||||
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
|
||||
["[MESSAGE]"] = envelope.Message ?? string.Empty,
|
||||
["[LINK_TO_DOCUMENT]"] = documentLink,
|
||||
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
|
||||
};
|
||||
|
||||
var subject = ReplacePlaceHolders(template.Subject, placeholders);
|
||||
var body = ReplacePlaceHolders(template.Body, placeholders);
|
||||
|
||||
var emailOut = new EmailOut
|
||||
await EnvNtfService.Notify(er.Receiver.EmailAddress, new DocumentReceivedNotification
|
||||
{
|
||||
AdvancedSignature = advancedSignature,
|
||||
DocumentTitle = envelope.Title ?? string.Empty,
|
||||
NameReceiver = er.Name ?? string.Empty,
|
||||
EnvelopeUuid = envelope.Uuid,
|
||||
IsReadAndConfirm = envelope.IsReadAndConfirm(),
|
||||
Message = envelope.Message ?? string.Empty,
|
||||
NameSender = $"{envelope.User.Prename} {envelope.User.Name}",
|
||||
Origin = origin,
|
||||
ReceiverSignature = er.Receiver.Signature,
|
||||
}, cancel);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
EmailAddress = receiver.EmailAddress,
|
||||
EmailSubj = subject,
|
||||
EmailBody = body,
|
||||
AddedWhen = DateTime.Now,
|
||||
AddedWho = DispatcherParams.AddedWho,
|
||||
SendingProfile = DispatcherParams.SendingProfile,
|
||||
ReminderTypeId = DispatcherParams.ReminderTypeId,
|
||||
EmailAttmt1 = DispatcherParams.EmailAttmt1,
|
||||
WfId = (int)EnvelopeStatus.MessageInvitationSent,
|
||||
ReferenceString = receiver.EmailAddress,
|
||||
ReferenceId = link.ReceiverId
|
||||
};
|
||||
|
||||
await EmailOutRepository.CreateAsync(emailOut, cancel);
|
||||
Logger.LogError(ex, "Failed to send invitation email for receiver {emailAddress} in envelope {envelopeId}.", er.Receiver.EmailAddress, envelope.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
|
||||
{
|
||||
foreach (var placeHolder in placeHolders)
|
||||
text = text.Replace(placeHolder.Key, placeHolder.Value);
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Application.Common.Query;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
||||
using EnvelopeGenerator.Application.Common.Dto.History;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
@@ -14,6 +17,11 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
|
||||
/// </summary>
|
||||
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool IncludeDocAndDocRes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
@@ -148,6 +156,86 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
|
||||
if (request is { OnlyCompleted: true })
|
||||
query = query.Where(e => Status.Completed.Contains(e.Status));
|
||||
|
||||
if (!request.IncludeDocAndDocRes)
|
||||
{
|
||||
return await query
|
||||
.Select(e => new EnvelopeDto
|
||||
{
|
||||
Id = e.Id,
|
||||
UserId = e.UserId,
|
||||
Status = e.Status,
|
||||
StatusName = string.Empty,
|
||||
Uuid = e.Uuid,
|
||||
Message = e.Message,
|
||||
AddedWhen = e.AddedWhen,
|
||||
ChangedWhen = e.ChangedWhen,
|
||||
Title = e.Title ?? string.Empty,
|
||||
ContractType = e.ContractType,
|
||||
Language = e.Language,
|
||||
EnvelopeTypeId = e.EnvelopeTypeId,
|
||||
CertificationType = e.CertificationType,
|
||||
FinalEmailToCreator = e.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = e.FinalEmailToReceivers,
|
||||
SendReminderEmails = e.SendReminderEmails,
|
||||
FirstReminderDays = e.FirstReminderDays,
|
||||
ReminderIntervalDays = e.ReminderIntervalDays,
|
||||
ExpiresWhenDays = e.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = e.ExpiresWarningWhenDays,
|
||||
UseAccessCode = e.UseAccessCode ?? true,
|
||||
TFAEnabled = e.TfaEnabled ?? false,
|
||||
EnvelopeTypeTitle = e.Type != null ? e.Type.Title : null,
|
||||
IsAlreadySent = false,
|
||||
DocResult = null,
|
||||
Documents = null,
|
||||
EnvelopeReceivers = e.EnvelopeReceivers.Select(er => new EnvelopeReceiverDto
|
||||
{
|
||||
EnvelopeId = er.EnvelopeId,
|
||||
ReceiverId = er.ReceiverId,
|
||||
Sequence = er.Sequence,
|
||||
Name = er.Name,
|
||||
JobTitle = er.JobTitle,
|
||||
CompanyName = er.CompanyName,
|
||||
PrivateMessage = er.PrivateMessage,
|
||||
AccessCode = er.AccessCode,
|
||||
AddedWhen = er.AddedWhen,
|
||||
ChangedWhen = er.ChangedWhen,
|
||||
HasPhoneNumber = er.HasPhoneNumber,
|
||||
Receiver = er.Receiver == null
|
||||
? null
|
||||
: new ReceiverDto
|
||||
{
|
||||
Id = er.Receiver.Id,
|
||||
EmailAddress = er.Receiver.EmailAddress,
|
||||
Signature = er.Receiver.Signature,
|
||||
AddedWhen = er.Receiver.AddedWhen,
|
||||
TfaRegDeadline = er.Receiver.TfaRegDeadline
|
||||
}
|
||||
}),
|
||||
Histories = e.Histories.Select(h => new HistoryDto
|
||||
{
|
||||
Id = h.Id,
|
||||
EnvelopeId = h.EnvelopeId,
|
||||
UserReference = h.UserReference,
|
||||
Status = h.Status,
|
||||
StatusName = null,
|
||||
AddedWhen = h.AddedWhen,
|
||||
ActionDate = h.ActionDate,
|
||||
Comment = h.Comment,
|
||||
Receiver = h.Receiver == null
|
||||
? null
|
||||
: new ReceiverDto
|
||||
{
|
||||
Id = h.Receiver.Id,
|
||||
EmailAddress = h.Receiver.EmailAddress,
|
||||
Signature = h.Receiver.Signature,
|
||||
AddedWhen = h.Receiver.AddedWhen,
|
||||
TfaRegDeadline = h.Receiver.TfaRegDeadline
|
||||
}
|
||||
})
|
||||
})
|
||||
.ToListAsync(cancel);
|
||||
}
|
||||
|
||||
var envelopes = await query
|
||||
.Include(e => e.Type)
|
||||
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
||||
|
||||
@@ -98,7 +98,8 @@ public class FinalizeEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender
|
||||
{
|
||||
DocumentTitle = envelope.Title,
|
||||
NameReceiver = er.Name,
|
||||
Attachment = doc
|
||||
Attachments = [($"unterzeichnetes-dokument.pdf", doc)],
|
||||
IsReadAndConfirm = envelope.IsReadAndConfirm(),
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
{
|
||||
public enum CertificationType
|
||||
{
|
||||
AdvancedElectronicSignature = 1
|
||||
// ElectronicSignature = 1
|
||||
// QualifiedSignature = 2
|
||||
AdvancedElectronicSignature = 1,
|
||||
ElectronicSignature = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
SendReminderEmails = false;
|
||||
FirstReminderDays = 0;
|
||||
ReminderIntervalDays = 0;
|
||||
CertificationType = (int)Constants.CertificationType.AdvancedElectronicSignature;
|
||||
CertificationType = Constants.CertificationType.AdvancedElectronicSignature;
|
||||
UseAccessCode = false;
|
||||
Documents = Enumerable.Empty<Document>().ToList();
|
||||
Histories = Enumerable.Empty<History>().ToList();
|
||||
@@ -83,7 +83,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public string Comment { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
#if NETFRAMEWORK
|
||||
[NotMapped]
|
||||
@@ -111,7 +111,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public bool ReadOnly => this.IsReadAndConfirm();
|
||||
|
||||
[Column("CERTIFICATION_TYPE")]
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
[Column("USE_ACCESS_CODE")]
|
||||
public bool? UseAccessCode { get; set; }
|
||||
@@ -129,7 +129,11 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
[ForeignKey("UserId")]
|
||||
public User User { get; set; }
|
||||
public User
|
||||
#if nullable
|
||||
?
|
||||
#endif
|
||||
User { get; set; }
|
||||
|
||||
[Column("TFA_ENABLED")]
|
||||
public bool? TfaEnabled { get; set; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#endif
|
||||
@@ -56,7 +58,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool TFA_Enabled { get; set; } = false;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
|
||||
public enum ConsumerType
|
||||
{
|
||||
API,
|
||||
RabbitMQ
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
|
||||
public class NotificationOptions
|
||||
{
|
||||
public const string SectionName = "Notification";
|
||||
|
||||
public ConsumerType ConsumerType { get; set; }
|
||||
|
||||
public string? ApiUrl { get; set; }
|
||||
|
||||
public string SenderEmail { get; set; } = null!;
|
||||
|
||||
public RabbitMqConnectionConfiguration? RabbitMQ { get; set; }
|
||||
}
|
||||
@@ -1,41 +1,47 @@
|
||||
using DigitalData.MessagingService.Client;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration, string sectionName = NotificationOptions.SectionName)
|
||||
{
|
||||
var rMqConfig = configuration.GetSection("RabbitMQ").Get<RabbitMqConfiguration>()
|
||||
?? throw new ArgumentNullException(nameof(RabbitMqConfiguration), "RabbitMQ configuration is missing");
|
||||
var ntfConfiguration = configuration.GetSection(sectionName);
|
||||
services.Configure<NotificationOptions>(ntfConfiguration);
|
||||
|
||||
EmailSender.ConnectRabbitMq(config =>
|
||||
var options = ntfConfiguration.Get<NotificationOptions>()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Failed to bind configuration section '{sectionName}' to {nameof(NotificationOptions)}. Ensure the section exists and is correctly configured.");
|
||||
|
||||
switch (options.ConsumerType)
|
||||
{
|
||||
config.HostName = rMqConfig.HostName;
|
||||
config.Port = rMqConfig.Port;
|
||||
config.UserName = rMqConfig.UserName;
|
||||
config.Password = rMqConfig.Password;
|
||||
|
||||
config.VirtualHost = "/";
|
||||
config.QueueName = "emailprofiler.email.outbox";
|
||||
config.ExchangeName = "emailprofiler.emails";
|
||||
config.RoutingKey = "email.outbox";
|
||||
config.DlqQueueName = "emailprofiler.email.outbox.dlq";
|
||||
config.DlqExchangeName = "emailprofiler.emails.dlq";
|
||||
config.DlqRoutingKey = "email.outbox.dlq";
|
||||
});
|
||||
|
||||
services.Configure<EmailAccount>(configuration.GetSection("NotificationEmailAccount"));
|
||||
case ConsumerType.API:
|
||||
if (options.ApiUrl is string apiUrl)
|
||||
services.AddMessagingServiceHttpClient(opt =>
|
||||
{
|
||||
opt.BaseAddress = new Uri(apiUrl);
|
||||
// Additional configuration for the MessagingServiceHttpClient can be added here if needed
|
||||
});
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"MessagingServiceApiUrl must be provided when ClientType is set to Api in the '{sectionName}' configuration section.");
|
||||
break;
|
||||
case ConsumerType.RabbitMQ:
|
||||
if(options.RabbitMQ is not null)
|
||||
services.AddMessagingServiceClient(ntfConfiguration, nameof(options.RabbitMQ));
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"Missing RabbitMQ connection configuration." +
|
||||
$"When ClientType is set to RabbitMQ, a valid RabbitMQ configuration must be provided in the '{sectionName}' configuration section.");
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported ConsumerType '{options.ConsumerType}' in the '{sectionName}' configuration section.");
|
||||
}
|
||||
|
||||
services.AddScoped<IEnvelopeNotificationService, EnvelopeNotificationService>();
|
||||
return services;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DigitalData.MessagingService.Client" Version="1.0.5" />
|
||||
<PackageReference Include="DigitalData.MessagingService.Client" Version="3.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class EnvelopeNotificationExtensions
|
||||
{
|
||||
public static EmailAttachmentDto ToEmailAttachmentDto(this (string FileName, byte[] Content) attachment) => new()
|
||||
{
|
||||
FileName = attachment.FileName,
|
||||
Content = attachment.Content
|
||||
};
|
||||
|
||||
public static IEnumerable<EmailAttachmentDto> ToEmailAttachmentDto(this IEnumerable<(string FileName, byte[] Content)> attachments)
|
||||
=> attachments.Select(ToEmailAttachmentDto);
|
||||
}
|
||||
@@ -1,44 +1,45 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Client;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public class EnvelopeNotificationService(IOptions<EmailAccount> EmailAccountOptions, IRepository<EmailTemplate> TempRepo) : IEnvelopeNotificationService
|
||||
public class EnvelopeNotificationService(IRepository<EmailTemplate> TempRepo, IMessagingServiceClient MsgClient, IOptions<NotificationOptions> ntfOpt) : IEnvelopeNotificationService
|
||||
{
|
||||
public async Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default)
|
||||
{
|
||||
var template = await TempRepo.Where(t => t.Name == "DocumentCompleted").FirstOrDefaultAsync(cancel)
|
||||
?? throw new NotFoundException("DocumentCompleted email template not found");
|
||||
var subjectTemplate = Replace(template.Subject, documentSigned);
|
||||
var bodyTemplate = Replace(template.Body, documentSigned);
|
||||
private readonly NotificationOptions NtfOpt = ntfOpt.Value;
|
||||
|
||||
await EmailSender.Send(new EmailContext()
|
||||
private async Task<EmailTemplate> GetTemplateAsync(EmailTemplateType templateType, CancellationToken cancel = default)
|
||||
{
|
||||
return await TempRepo.Where(t => t.Name == templateType.ToString()).FirstOrDefaultAsync(cancel)
|
||||
?? throw new DataIntegrityException($"{templateType} email template not found");
|
||||
}
|
||||
|
||||
public async Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
|
||||
where TEnvelopeNotificationBase : EnvelopeNotificationBase
|
||||
{
|
||||
var template = await GetTemplateAsync(notification.GetTemplateType(), cancel);
|
||||
|
||||
// create email command
|
||||
var mail = new PublishEmailViaSmtpCommand()
|
||||
{
|
||||
Body = bodyTemplate,
|
||||
Subject = subjectTemplate,
|
||||
Recipients = [email],
|
||||
Sender = EmailAccountOptions.Value,
|
||||
Attachments = [new EmailAttachmentDto()
|
||||
{
|
||||
FileName = "unterzeichnetes-dokument.pdf",
|
||||
Content = documentSigned.Attachment
|
||||
}]
|
||||
});
|
||||
}
|
||||
Body = template.Body.ReplacePlaceholders(notification),
|
||||
Subject = template.Subject.ReplacePlaceholders(notification),
|
||||
Recipients = [receiverEmail],
|
||||
Sender = NtfOpt.SenderEmail.ToSender(),
|
||||
IsHtml = true
|
||||
};
|
||||
|
||||
private static string Replace(string template, DocumentSignedNotification documentSigned)
|
||||
{
|
||||
return template
|
||||
.Replace("[DOCUMENT_TITLE]", documentSigned.DocumentTitle)
|
||||
.Replace("[NAME_PORTAL]", documentSigned.NamePortal)
|
||||
.Replace("[NAME_RECEIVER]", documentSigned.NameReceiver);
|
||||
// add attachments if any
|
||||
if(notification.Attachments?.Any() ?? false)
|
||||
mail = mail.WithAttachments(notification.Attachments.ToEmailAttachmentDto());
|
||||
|
||||
await MsgClient.SendAsync(mail, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class ReplacePlaceholderExtensions
|
||||
{
|
||||
private sealed record PlaceholderProperty(string Placeholder, PropertyInfo Property);
|
||||
|
||||
private static readonly ConcurrentDictionary<Type, PlaceholderProperty[]> PropertyCache = new();
|
||||
|
||||
public static string ReplacePlaceholders(this string template, object data)
|
||||
{
|
||||
var properties = PropertyCache.GetOrAdd(data.GetType(), CreatePlaceholderProperties);
|
||||
|
||||
foreach (var item in properties)
|
||||
{
|
||||
var value = item.Property.GetValue(data)?.ToString() ?? string.Empty;
|
||||
template = template.Replace(
|
||||
item.Placeholder,
|
||||
value,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private static PlaceholderProperty[] CreatePlaceholderProperties(
|
||||
Type type)
|
||||
{
|
||||
return [.. type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => new PlaceholderProperty(
|
||||
$"[{ToCapitalSnakeCase(p.Name)}]",
|
||||
p))];
|
||||
}
|
||||
|
||||
private static string ToCapitalSnakeCase(string name)
|
||||
{
|
||||
return Regex.Replace(
|
||||
Regex.Replace(name, @"([A-Z]+)([A-Z][a-z])", "$1_$2"),
|
||||
@"([a-z0-9])([A-Z])",
|
||||
"$1_$2").ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Original email template: DocumentAccessCodeReceived (GUID 5)
|
||||
-- Backed up before redesign: 2026-10-29 16:32:15
|
||||
-- Subject: Zugriffscode für Dokument erhalten: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet. <br />
|
||||
<br />
|
||||
Verwenden Sie den folgenden Zugriffscode, um das Dokument einzusehen:<br />
|
||||
<br />
|
||||
[DOCUMENT_ACCESS_CODE]<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 5;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Original email template: DocumentCompleted (GUID 4)
|
||||
-- Backed up before redesign: 2026-10-29 16:29:55
|
||||
-- Subject: Umschlag abgeschlossen: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br />
|
||||
Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde erfolgreich abgeschlossen.<br />
|
||||
<br />
|
||||
Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 4;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Original email template: DocumentDeleted (GUID 2)
|
||||
-- Backed up before redesign: 2026-10-29 16:23:45
|
||||
-- Subject: Umschlag zurückgezogen: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> gelöscht/zurückgezogen.<br /><p>
|
||||
Begründung: <br /> <I>[REASON]</I> <p>
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Original email template: DocumentReceived (GUID 1, de-DE)
|
||||
-- Backed up before redesign: 2026-10-29 16:06:07
|
||||
-- Subject: Dokument erhalten: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet.<br />
|
||||
<br />
|
||||
Über den folgenden Link können Sie das Dokument einsehen[DOCUMENT_PROCESS]: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br />
|
||||
<br />
|
||||
[MESSAGE]<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 1;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Original email template: DocumentRejected_ADM (GUID 6)
|
||||
-- Backed up before redesign: 2026-10-29 16:33:34
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang zurückgezogen
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_SENDER],<p><B><I>[NAME_RECEIVER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> mit folgendem Grund abgelehnt: <p>
|
||||
[REASON]
|
||||
<p>Der Umschlag wurde auf den Status Rejected gesetzt. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 6;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Original email template: DocumentRejected_REC_2 (GUID 10)
|
||||
-- Backed up before redesign: 2026-10-29 16:37:12
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang abgelehnt.
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],
|
||||
<p>Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde durch einen anderen Vertragspartner abgelehnt.<p> Der Absender <B><I>[NAME_SENDER]</I></B> wird sich bei Bedarf mit Ihnen in Verbindung setzen. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 10;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Original email template: DocumentRejected_REC (GUID 9)
|
||||
-- Backed up before redesign: 2026-10-29 16:34:21
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Bestätigung der Ablehnung
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],
|
||||
<p>Hiermit bestätigen wir Ihnen die Ablehnung des Vorgangs <B><I>''[DOCUMENT_TITLE]''</I></B>!<p>Der Absender <B><I>[NAME_SENDER]</I></B> wurde über die Ablehnung informiert. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 9;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Original email template: DocumentShared (GUID 11)
|
||||
-- Backed up before redesign: 2026-10-29 16:42:51
|
||||
-- Subject: Dokument geteilt: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag,<br /> <br /><B><I> [NAME_RECEIVER]</I></B> hat Ihnen ein Dokument zum Ansehen gesendet.<br /> <br /> Über den folgenden Link können Sie das Dokument einsehen: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br /> <br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
|
||||
WHERE GUID = 11;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Original email template: DocumentSigned (GUID 3)
|
||||
-- Backed up before redesign: 2026-10-29 16:25:45
|
||||
-- Subject: Dokument [RECEIVER_ACTION]: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br />
|
||||
hiermit bestätigen wir Ihnen die erfolgreiche [FINAL_STATUS] für den Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B>.<br />
|
||||
Wenn alle [FINAL_ACTION] haben, erhalten Sie ebenfalls per E-Mail ein abgeschlossenes Exemplar mit entsprechendem Zertifikat!
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 3;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Original email template: TotpSecret (GUID 12)
|
||||
-- Backed up before redesign: 2026-10-29 16:44:44
|
||||
-- Subject: 2-Faktor-Authentifizierung QR-Code
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag,<br /> <br />Sie können auf Ihren Zwei-Faktor-Authentifizierungscode zugreifen, indem Sie den unten stehenden QR-Code mit einer beliebigen Authentifizierungs-App auf Ihrem Telefon scannen (Google Authenticator, Microsoft Authenticator usw.). Dieser Code ist bis zum [TFA_EXPIRATION] gültig.<br /> <br /> <img src="data:image/png;base64,[TFA_QR_CODE]" style="width: 13rem; height: 13rem;"><br /> <br />
|
||||
<br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
|
||||
WHERE GUID = 12;
|
||||
@@ -5,6 +5,7 @@
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IStringLocalizer<Model> ModelLocalizer
|
||||
@@ -27,334 +28,341 @@
|
||||
<div class="sender-create-wizard"
|
||||
tabindex="0"
|
||||
@onkeydown="OnWizardKeyDownAsync">
|
||||
<div class="sender-create-wizard__progress">
|
||||
<div class="sender-create-wizard__progress-track"></div>
|
||||
@for (var step = 0; step < TotalSteps; step++)
|
||||
{
|
||||
var isActive = step == _currentStep;
|
||||
var isDone = step < _currentStep;
|
||||
<div class="sender-create-wizard__step @(isActive ? "sender-create-wizard__step--active" : string.Empty) @(isDone ? "sender-create-wizard__step--done" : string.Empty)">
|
||||
<span class="sender-create-wizard__step-index">@(step + 1)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<MudPaper Class="sender-create-wizard__stepper-shell" Elevation="0">
|
||||
<MudStepper Vertical="true"
|
||||
NonLinear="false"
|
||||
ShowResetButton="false"
|
||||
ActiveIndex="@_currentStep"
|
||||
Class="sender-create-wizard__stepper">
|
||||
<ChildContent>
|
||||
@for (var step = 0; step < TotalSteps; step++)
|
||||
{
|
||||
var stepIndex = step;
|
||||
|
||||
<div class="sender-create-wizard__meta">
|
||||
<div class="sender-create-wizard__title">@CurrentStepTitle</div>
|
||||
<div class="sender-create-wizard__description">@CurrentStepDescription</div>
|
||||
</div>
|
||||
<MudStep Title="@GetStepTitle(stepIndex)">
|
||||
<div class="sender-create-wizard__stepper-description">@GetStepDescription(stepIndex)</div>
|
||||
|
||||
@if (stepIndex == _currentStep)
|
||||
{
|
||||
<div class="sender-create-wizard__step-content">
|
||||
@if (_currentStep == 0)
|
||||
{
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Title", "Titel") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_title"
|
||||
TextChanged="OnTitleChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("EnvelopeType", "Umschlagtyp") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
|
||||
@onchange="OnEnvelopeTypeChanged"
|
||||
class="sender-create-wizard__select">
|
||||
@foreach (var type in EnvelopeTypes)
|
||||
{
|
||||
<option value="@type.Id">@type.Title</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 1)
|
||||
{
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Message", "Nachricht") <span class="sender-create-wizard__optional">(@L("Optional", "optional"))</span>
|
||||
</label>
|
||||
<DxMemo Text="@_message"
|
||||
TextChanged="OnMessageChanged"
|
||||
Rows="5"
|
||||
MaxLength="500"
|
||||
NullText='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Language", "Sprache")
|
||||
</label>
|
||||
<select @bind="_language" class="sender-create-wizard__select">
|
||||
<option value="de-DE">Deutsch (de-DE)</option>
|
||||
<option value="en-US">English (en-US)</option>
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">English (en)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 2)
|
||||
{
|
||||
<div class="sender-create-wizard__settings-grid">
|
||||
<div class="sender-create-wizard__settings-group">
|
||||
<div class="sender-create-wizard__settings-title">
|
||||
@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType" class="sender-create-wizard__select">
|
||||
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
|
||||
<select @bind="_finalEmailToCreator" class="sender-create-wizard__select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
|
||||
<select @bind="_finalEmailToReceivers" class="sender-create-wizard__select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<div class="sender-create-wizard__settings-toggles">
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
|
||||
@L("TFAEnabled", "2FA aktiviert")
|
||||
</label>
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
|
||||
@L("UseAccessCode", "Zugriffscode verwenden")
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__settings-group">
|
||||
<div class="sender-create-wizard__settings-title">
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
|
||||
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 3)
|
||||
{
|
||||
<div class="sender-create-wizard__suggestions">
|
||||
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
|
||||
|
||||
@if (_isLoadingRecentSuggestions)
|
||||
{
|
||||
<div class="sender-create-wizard__empty-list">@L("Loading", "Lädt...")</div>
|
||||
}
|
||||
else if (_recentReceiverSuggestions.Count > 0)
|
||||
{
|
||||
<div class="sender-create-wizard__suggestion-list" role="listbox" aria-label="Recent receivers">
|
||||
@foreach (var suggestion in FilteredRecentSuggestions)
|
||||
{
|
||||
<button type="button"
|
||||
class="sender-create-wizard__suggestion-item"
|
||||
@onclick="() => ApplyRecentSuggestion(suggestion)"
|
||||
disabled="@IsActionLocked">
|
||||
<span class="sender-create-wizard__suggestion-name">@suggestion.FullName</span>
|
||||
<span class="sender-create-wizard__suggestion-email">@suggestion.EmailAddress</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("FullName", "Vor- und Nachname") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_receiverName"
|
||||
TextChanged="OnReceiverNameChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText="Max Mustermann"
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("EmailAddress", "E-Mail-Adresse") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_receiverEmail"
|
||||
TextChanged="OnReceiverEmailChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText="name@beispiel.de"
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__receiver-actions">
|
||||
<button type="button"
|
||||
class="sender-create-wizard__add-receiver-btn"
|
||||
@onclick="AddReceiver"
|
||||
disabled="@IsActionLocked">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V7.5H11.5a.5.5 0 0 1 0 1H8.5v3a.5.5 0 0 1-1 0v-3H4.5a.5.5 0 0 1 0-1H7.5v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
<span>@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_receivers.Count == 0)
|
||||
{
|
||||
<div class="sender-create-wizard__empty-list">
|
||||
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-create-wizard__receiver-tagbox-wrap">
|
||||
<label class="sender-create-wizard__label" for="wizardReceiversTagBox">
|
||||
@L("Receiver", "Empfänger")
|
||||
</label>
|
||||
<DxTagBox Data="@_receivers"
|
||||
TData="CreateEnvelopeWizardReceiver"
|
||||
TValue="CreateEnvelopeWizardReceiver"
|
||||
AllowCustomTags="true"
|
||||
@bind-Tags="@_receiverTags"
|
||||
@bind-Tags:after="OnReceiverTagsChanged"
|
||||
HideSelectedItems="false"
|
||||
TextFieldName="@nameof(CreateEnvelopeWizardReceiver.EmailAddress)"
|
||||
ListRenderMode="ListRenderMode.Virtual"
|
||||
SearchMode="ListSearchMode.AutoSearch"
|
||||
SearchFilterCondition="ListSearchFilterCondition.Contains"
|
||||
SearchTextParseMode="ListSearchTextParseMode.GroupWordsByAnd"
|
||||
CssClass="sender-create-wizard__receiver-tagbox"
|
||||
InputId="wizardReceiversTagBox"
|
||||
Enabled="@(!IsActionLocked)">
|
||||
<TagDisplayTemplate Context="tagContext">
|
||||
<div class="sender-create-wizard__receiver-tag">
|
||||
@if (tagContext.DataItem is CreateEnvelopeWizardReceiver receiver)
|
||||
{
|
||||
<span class="sender-create-wizard__receiver-tag-name">@receiver.FullName</span>
|
||||
<span class="sender-create-wizard__receiver-tag-email">@receiver.EmailAddress</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="sender-create-wizard__receiver-tag-email">@tagContext.DisplayText</span>
|
||||
}
|
||||
<button type="button"
|
||||
class="sender-create-wizard__receiver-tag-remove"
|
||||
@onclick="tagContext.RemoveTagAction"
|
||||
aria-label="Remove receiver"
|
||||
disabled="@IsActionLocked">x</button>
|
||||
</div>
|
||||
</TagDisplayTemplate>
|
||||
<EmptyDataAreaTemplate Context="emptyContext">
|
||||
<div class="sender-create-wizard__tagbox-empty"></div>
|
||||
</EmptyDataAreaTemplate>
|
||||
</DxTagBox>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-create-wizard__upload-stage">
|
||||
<label class="sender-create-wizard__upload-btn @(IsActionLocked ? "sender-create-wizard__upload-btn--disabled" : string.Empty)">
|
||||
@if (_documentBytes is null)
|
||||
{
|
||||
<span>@L("UploadPdf", "PDF hochladen")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@L("SenderWizard_ReplacePdf", "PDF ersetzen")</span>
|
||||
}
|
||||
<InputFile OnChange="OnDocumentSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@IsActionLocked"
|
||||
class="sender-create-wizard__hidden-input" />
|
||||
</label>
|
||||
|
||||
@if (_documentBytes is not null)
|
||||
{
|
||||
<div class="sender-create-wizard__document-preview-card">
|
||||
<div class="sender-create-wizard__document-meta">
|
||||
<div class="sender-create-wizard__document-name">@_documentFileName</div>
|
||||
<div class="sender-create-wizard__document-size">@_documentSizeText</div>
|
||||
</div>
|
||||
<div class="sender-create-wizard__document-preview-wrap">
|
||||
<SenderPdfImageEditor IsPlacementMode="false"
|
||||
PageCount="@_documentPreviewPageCount"
|
||||
Pages="@_documentPreviewPages"
|
||||
PdfDataUrl="@DocumentPreviewDataUrl"
|
||||
PdfFileName="@_documentFileName"
|
||||
Fields="@_documentPreviewFieldOverlays"
|
||||
EditorClick="OnDocumentPreviewEditorClickAsync"
|
||||
RemoveField="OnDocumentPreviewRemoveFieldAsync"
|
||||
MoveField="OnDocumentPreviewMoveFieldAsync" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PdfFileDropZone Disabled="@IsActionLocked"
|
||||
HasDocument="false"
|
||||
UploadText='@L("SenderWizard_DocumentRequiredHint", "Bitte laden Sie ein PDF-Dokument hoch, um fortzufahren.")'
|
||||
DragDropHintText='@L("SenderWizard_DragDropPdfHint", "Oder ziehen Sie die PDF-Datei hierher.")'
|
||||
FileSelected="OnDocumentSelectedAsync" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudStep>
|
||||
}
|
||||
</ChildContent>
|
||||
</MudStepper>
|
||||
</MudPaper>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_validationMessage))
|
||||
{
|
||||
<div class="sender-create-wizard__validation">@_validationMessage</div>
|
||||
}
|
||||
|
||||
@if (_currentStep == 0)
|
||||
{
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Title", "Titel") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_title"
|
||||
TextChanged="OnTitleChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("EnvelopeType", "Umschlagtyp") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
|
||||
@onchange="OnEnvelopeTypeChanged"
|
||||
class="sender-create-wizard__select">
|
||||
@foreach (var type in EnvelopeTypes)
|
||||
{
|
||||
<option value="@type.Id">@type.Title</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 1)
|
||||
{
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Message", "Nachricht") <span class="sender-create-wizard__optional">(@L("Optional", "optional"))</span>
|
||||
</label>
|
||||
<DxMemo Text="@_message"
|
||||
TextChanged="OnMessageChanged"
|
||||
Rows="5"
|
||||
MaxLength="500"
|
||||
NullText='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("Language", "Sprache")
|
||||
</label>
|
||||
<select @bind="_language" class="sender-create-wizard__select">
|
||||
<option value="de-DE">Deutsch (de-DE)</option>
|
||||
<option value="en-US">English (en-US)</option>
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">English (en)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 2)
|
||||
{
|
||||
<div class="sender-create-wizard__settings-grid">
|
||||
<div class="sender-create-wizard__settings-group">
|
||||
<div class="sender-create-wizard__settings-title">
|
||||
@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType" class="sender-create-wizard__select">
|
||||
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
|
||||
<select @bind="_finalEmailToCreator" class="sender-create-wizard__select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
|
||||
<select @bind="_finalEmailToReceivers" class="sender-create-wizard__select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<div class="sender-create-wizard__settings-toggles">
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
|
||||
@L("TFAEnabled", "2FA aktiviert")
|
||||
</label>
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
|
||||
@L("UseAccessCode", "Zugriffscode verwenden")
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__settings-group">
|
||||
<div class="sender-create-wizard__settings-title">
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field sender-create-wizard__field--full">
|
||||
<label class="sender-create-wizard__settings-toggle">
|
||||
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
|
||||
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_currentStep == 3)
|
||||
{
|
||||
<div class="sender-create-wizard__suggestions">
|
||||
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
|
||||
|
||||
@if (_isLoadingRecentSuggestions)
|
||||
{
|
||||
<div class="sender-create-wizard__empty-list">@L("Loading", "Lädt...")</div>
|
||||
}
|
||||
else if (_recentReceiverSuggestions.Count > 0)
|
||||
{
|
||||
<div class="sender-create-wizard__suggestion-list" role="listbox" aria-label="Recent receivers">
|
||||
@foreach (var suggestion in FilteredRecentSuggestions)
|
||||
{
|
||||
<button type="button"
|
||||
class="sender-create-wizard__suggestion-item"
|
||||
@onclick="() => ApplyRecentSuggestion(suggestion)"
|
||||
disabled="@IsActionLocked">
|
||||
<span class="sender-create-wizard__suggestion-name">@suggestion.FullName</span>
|
||||
<span class="sender-create-wizard__suggestion-email">@suggestion.EmailAddress</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__form-grid">
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("FullName", "Vor- und Nachname") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_receiverName"
|
||||
TextChanged="OnReceiverNameChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText="Max Mustermann"
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__field">
|
||||
<label class="sender-create-wizard__label">
|
||||
@L("EmailAddress", "E-Mail-Adresse") <span class="sender-create-wizard__required">*</span>
|
||||
</label>
|
||||
<DxTextBox Text="@_receiverEmail"
|
||||
TextChanged="OnReceiverEmailChanged"
|
||||
BindValueMode="BindValueMode.OnInput"
|
||||
NullText="name@beispiel.de"
|
||||
CssClass="w-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-create-wizard__receiver-actions">
|
||||
<button type="button"
|
||||
class="sender-create-wizard__add-receiver-btn"
|
||||
@onclick="AddReceiver"
|
||||
disabled="@IsActionLocked">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V7.5H11.5a.5.5 0 0 1 0 1H8.5v3a.5.5 0 0 1-1 0v-3H4.5a.5.5 0 0 1 0-1H7.5v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
<span>@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_receivers.Count == 0)
|
||||
{
|
||||
<div class="sender-create-wizard__empty-list">
|
||||
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-create-wizard__receiver-tagbox-wrap">
|
||||
<label class="sender-create-wizard__label" for="wizardReceiversTagBox">
|
||||
@L("Receiver", "Empfänger")
|
||||
</label>
|
||||
<DxTagBox Data="@_receivers"
|
||||
TData="CreateEnvelopeWizardReceiver"
|
||||
TValue="CreateEnvelopeWizardReceiver"
|
||||
AllowCustomTags="true"
|
||||
@bind-Tags="@_receiverTags"
|
||||
@bind-Tags:after="OnReceiverTagsChanged"
|
||||
HideSelectedItems="false"
|
||||
TextFieldName="@nameof(CreateEnvelopeWizardReceiver.EmailAddress)"
|
||||
ListRenderMode="ListRenderMode.Virtual"
|
||||
SearchMode="ListSearchMode.AutoSearch"
|
||||
SearchFilterCondition="ListSearchFilterCondition.Contains"
|
||||
SearchTextParseMode="ListSearchTextParseMode.GroupWordsByAnd"
|
||||
CssClass="sender-create-wizard__receiver-tagbox"
|
||||
InputId="wizardReceiversTagBox"
|
||||
Enabled="@(!IsActionLocked)">
|
||||
<TagDisplayTemplate Context="tagContext">
|
||||
<div class="sender-create-wizard__receiver-tag">
|
||||
@if (tagContext.DataItem is CreateEnvelopeWizardReceiver receiver)
|
||||
{
|
||||
<span class="sender-create-wizard__receiver-tag-name">@receiver.FullName</span>
|
||||
<span class="sender-create-wizard__receiver-tag-email">@receiver.EmailAddress</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="sender-create-wizard__receiver-tag-email">@tagContext.DisplayText</span>
|
||||
}
|
||||
<button type="button"
|
||||
class="sender-create-wizard__receiver-tag-remove"
|
||||
@onclick="tagContext.RemoveTagAction"
|
||||
aria-label="Remove receiver"
|
||||
disabled="@IsActionLocked">x</button>
|
||||
</div>
|
||||
</TagDisplayTemplate>
|
||||
<EmptyDataAreaTemplate Context="emptyContext">
|
||||
<div class="sender-create-wizard__tagbox-empty"></div>
|
||||
</EmptyDataAreaTemplate>
|
||||
</DxTagBox>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-create-wizard__upload-stage">
|
||||
<label class="sender-create-wizard__upload-btn @(IsActionLocked ? "sender-create-wizard__upload-btn--disabled" : string.Empty)">
|
||||
@if (_documentBytes is null)
|
||||
{
|
||||
<span>@L("UploadPdf", "PDF hochladen")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@L("SenderWizard_ReplacePdf", "PDF ersetzen")</span>
|
||||
}
|
||||
<InputFile OnChange="OnDocumentSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@IsActionLocked"
|
||||
class="sender-create-wizard__hidden-input" />
|
||||
</label>
|
||||
|
||||
@if (_documentBytes is not null)
|
||||
{
|
||||
<div class="sender-create-wizard__document-preview-card">
|
||||
<div class="sender-create-wizard__document-meta">
|
||||
<div class="sender-create-wizard__document-name">@_documentFileName</div>
|
||||
<div class="sender-create-wizard__document-size">@_documentSizeText</div>
|
||||
</div>
|
||||
<div class="sender-create-wizard__document-preview-wrap">
|
||||
<SenderPdfImageEditor IsPlacementMode="false"
|
||||
PageCount="@_documentPreviewPageCount"
|
||||
Pages="@_documentPreviewPages"
|
||||
PdfDataUrl="@DocumentPreviewDataUrl"
|
||||
PdfFileName="@_documentFileName"
|
||||
Fields="@_documentPreviewFieldOverlays"
|
||||
EditorClick="OnDocumentPreviewEditorClickAsync"
|
||||
RemoveField="OnDocumentPreviewRemoveFieldAsync"
|
||||
MoveField="OnDocumentPreviewMoveFieldAsync" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PdfFileDropZone Disabled="@IsActionLocked"
|
||||
HasDocument="false"
|
||||
UploadText='@L("SenderWizard_DocumentRequiredHint", "Bitte laden Sie ein PDF-Dokument hoch, um fortzufahren.")'
|
||||
DragDropHintText='@L("SenderWizard_DragDropPdfHint", "Oder ziehen Sie die PDF-Datei hierher.")'
|
||||
FileSelected="OnDocumentSelectedAsync" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
@@ -421,7 +429,7 @@
|
||||
private string _language = "de-DE";
|
||||
private bool _useAccessCode;
|
||||
private bool _tfaEnabled;
|
||||
private int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
|
||||
private CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
|
||||
private int _finalEmailToCreator = (int)FinalEmailType.No;
|
||||
private int _finalEmailToReceivers = (int)FinalEmailType.No;
|
||||
private bool _sendReminderEmails;
|
||||
@@ -466,7 +474,11 @@
|
||||
? $"data:application/pdf;base64,{Convert.ToBase64String(_documentBytes)}"
|
||||
: null;
|
||||
|
||||
private string CurrentStepTitle => _currentStep switch
|
||||
private string CurrentStepTitle => GetStepTitle(_currentStep);
|
||||
|
||||
private string CurrentStepDescription => GetStepDescription(_currentStep);
|
||||
|
||||
private string GetStepTitle(int step) => step switch
|
||||
{
|
||||
0 => L("SenderWizard_Step_Basics", "Grunddaten"),
|
||||
1 => L("SenderWizard_Step_Message", "Nachricht und Optionen"),
|
||||
@@ -476,7 +488,7 @@
|
||||
_ => L("SenderWizard_Step_Basics", "Grunddaten")
|
||||
};
|
||||
|
||||
private string CurrentStepDescription => _currentStep switch
|
||||
private string GetStepDescription(int step) => step switch
|
||||
{
|
||||
0 => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag."),
|
||||
1 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."),
|
||||
@@ -1116,7 +1128,7 @@
|
||||
_language = "de-DE";
|
||||
_useAccessCode = false;
|
||||
_tfaEnabled = false;
|
||||
_certificationType = (int)CertificationType.AdvancedElectronicSignature;
|
||||
_certificationType = CertificationType.AdvancedElectronicSignature;
|
||||
_finalEmailToCreator = (int)FinalEmailType.No;
|
||||
_finalEmailToReceivers = (int)FinalEmailType.No;
|
||||
_sendReminderEmails = false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender;
|
||||
|
||||
public sealed record CreateEnvelopeWizardResult(
|
||||
@@ -7,7 +9,7 @@ public sealed record CreateEnvelopeWizardResult(
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MudBlazor" Version="8.0.0" />
|
||||
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Application.Common.Configurations
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using Microsoft.Extensions.Options
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AppVersionService AppVersion
|
||||
@@ -19,23 +20,19 @@
|
||||
@inject DocumentService DocumentService
|
||||
@inject PdfRenderService PdfRenderService
|
||||
@inject ReceiverService ReceiverService
|
||||
@inject EnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject EnvelopeService EnvelopeService
|
||||
@inject EnvelopeTypeService EnvelopeTypeService
|
||||
@inject CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
|
||||
@inject AuthService AuthService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IStringLocalizer<Model> ModelLocalizer
|
||||
@inject IOptionsMonitor<SignatureFieldParams> SignatureFieldOptions
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
|
||||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||||
|
||||
<DxLoadingPanel Visible="@IsBootstrapLoading"
|
||||
IsContentBlocked="true"
|
||||
ApplyBackgroundShading="true"
|
||||
IndicatorAreaVisible="true"
|
||||
Text='@L("SenderEditor_BootstrapLoadingHint", "Der Umschlag wird vorbereitet und die Daten werden uebernommen.")'>
|
||||
<div class="envelope-viewer-layout">
|
||||
|
||||
@* ── Action Bar ── *@
|
||||
@@ -126,9 +123,8 @@
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType" class="sender-settings-select">
|
||||
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
|
||||
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -196,20 +192,43 @@
|
||||
</div>
|
||||
|
||||
<div class="sender-receivers-panel">
|
||||
<div class="sender-receivers-panel__header">
|
||||
<div class="sender-receivers-panel__title-wrap">
|
||||
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
|
||||
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
||||
<div class="sender-receivers-list">
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-receivers-list__add-btn"
|
||||
@onclick="OpenAddReceiverPopup"
|
||||
title='@L("AddReceiver", "Empfänger hinzufügen")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
<span class="sender-receivers-list__add-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
<span class="badge rounded-pill text-bg-primary sender-receivers-list__count">@_receivers.Count</span>
|
||||
</button>
|
||||
|
||||
@foreach (var receiver in _receivers)
|
||||
{
|
||||
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
|
||||
<div class="sender-receiver-chip__body">
|
||||
<div class="sender-receiver-chip__email" title="@receiver.FullName">@receiver.Email</div>
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__remove-btn"
|
||||
@onclick="() => RemoveReceiverAsync(receiver)"
|
||||
title='@L("Delete", "Loeschen")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__place-btn
|
||||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
|
||||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||
style="--receiver-color:@receiver.Color;"
|
||||
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_receivers.Count == 0)
|
||||
@@ -218,35 +237,6 @@
|
||||
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-receivers-list">
|
||||
@foreach (var receiver in _receivers)
|
||||
{
|
||||
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
|
||||
<div class="sender-receiver-chip__body">
|
||||
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
||||
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
||||
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
|
||||
{
|
||||
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__place-btn
|
||||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
|
||||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||
style="--receiver-color:@receiver.Color;"
|
||||
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -431,7 +421,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</DxLoadingPanel>
|
||||
|
||||
<DxPopup @bind-Visible="_receiverPopupVisible"
|
||||
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
|
||||
@@ -596,13 +585,11 @@
|
||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||
public int? EnvelopeId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "bootstrapToken")]
|
||||
public string? BootstrapToken { get; set; }
|
||||
|
||||
// ── Constants ──
|
||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||
// ── Signature field size (configurable) ──
|
||||
double SigWidthInches => SignatureFieldOptions.CurrentValue.Width;
|
||||
double SigHeightInches => SignatureFieldOptions.CurrentValue.Height;
|
||||
double SigWidthPt => SigWidthInches * 72.0;
|
||||
double SigHeightPt => SigHeightInches * 72.0;
|
||||
|
||||
// CssClass for JS click coordinate helper
|
||||
const string ViewerCssClass = "sender-editor-pdf-viewer";
|
||||
@@ -617,10 +604,7 @@
|
||||
int _pageCount;
|
||||
List<PdfPageImageDto> _pagePreviews = [];
|
||||
int? _loadedEnvelopeId;
|
||||
int? _editingEnvelopeId;
|
||||
bool _isLoadingEnvelopeForEdit;
|
||||
bool _bootstrapApplied;
|
||||
string? _pendingBootstrapToken;
|
||||
|
||||
List<SignatureFieldDraft> _signatureFields = [];
|
||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||
@@ -641,7 +625,6 @@
|
||||
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
||||
string _saveSuccessBody = string.Empty;
|
||||
bool _saveNavigateToDashboard = false;
|
||||
bool _saveDraftAfterBootstrap;
|
||||
|
||||
// ── Envelope metadata ──
|
||||
string _envelopeTitle = string.Empty;
|
||||
@@ -653,7 +636,7 @@
|
||||
string _language = "de-DE";
|
||||
bool _useAccessCode;
|
||||
bool _tfaEnabled;
|
||||
int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
|
||||
CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
|
||||
int _finalEmailToCreator = (int)FinalEmailType.No;
|
||||
int _finalEmailToReceivers = (int)FinalEmailType.No;
|
||||
bool _sendReminderEmails;
|
||||
@@ -675,9 +658,6 @@
|
||||
|
||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||
|
||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||
bool IsBootstrapLoading => !IsEditMode && !_bootstrapApplied;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -692,12 +672,6 @@
|
||||
try
|
||||
{
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
|
||||
if (!IsEditMode && _selectedEnvelopeTypeId is null && _envelopeTypes.Count > 0)
|
||||
{
|
||||
_selectedEnvelopeTypeId = _envelopeTypes[0].Id;
|
||||
ApplyEnvelopeTypeDefaults(_envelopeTypes[0]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -708,95 +682,14 @@
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
|
||||
_editingEnvelopeId = EnvelopeId.Value;
|
||||
|
||||
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
|
||||
{
|
||||
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEditMode || _bootstrapApplied)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BootstrapToken))
|
||||
if (!EnvelopeId.HasValue || EnvelopeId.Value <= 0)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", replace: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingBootstrapToken = BootstrapToken;
|
||||
}
|
||||
|
||||
async Task TryApplyBootstrapAsync(string token)
|
||||
{
|
||||
var payload = await CreateEnvelopeBootstrapStorageService.ConsumeAsync(token);
|
||||
_bootstrapApplied = true;
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", replace: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_envelopeTitle = payload.Title ?? string.Empty;
|
||||
_envelopeMessage = payload.Message ?? string.Empty;
|
||||
_selectedEnvelopeTypeId = payload.EnvelopeTypeId;
|
||||
_language = string.IsNullOrWhiteSpace(payload.Language) ? "de-DE" : payload.Language;
|
||||
_useAccessCode = payload.UseAccessCode;
|
||||
_tfaEnabled = payload.TFAEnabled;
|
||||
_certificationType = payload.CertificationType;
|
||||
_finalEmailToCreator = payload.FinalEmailToCreator;
|
||||
_finalEmailToReceivers = payload.FinalEmailToReceivers;
|
||||
_sendReminderEmails = payload.SendReminderEmails;
|
||||
_firstReminderDays = payload.FirstReminderDays;
|
||||
_reminderIntervalDays = payload.ReminderIntervalDays;
|
||||
_expiresWhenDays = payload.ExpiresWhenDays;
|
||||
_expiresWarningWhenDays = payload.ExpiresWarningWhenDays;
|
||||
NormalizeSettings();
|
||||
|
||||
_receivers = payload.Receivers
|
||||
.Select((receiver, index) => new ReceiverDraft(
|
||||
Guid.NewGuid(),
|
||||
receiver.FullName,
|
||||
receiver.EmailAddress,
|
||||
string.Empty,
|
||||
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||
.ToList();
|
||||
|
||||
_originalPdfBytes = payload.DocumentBytes;
|
||||
_fileName = string.IsNullOrWhiteSpace(payload.DocumentFileName) ? "document.pdf" : payload.DocumentFileName;
|
||||
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||
|
||||
_signatureFields.Clear();
|
||||
_pendingReceiverForPlacement = null;
|
||||
|
||||
_saveDraftAfterBootstrap = true;
|
||||
|
||||
if (_pdfLoaded && _originalPdfBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to render bootstrap PDF previews.");
|
||||
_pagePreviews = [];
|
||||
}
|
||||
|
||||
_pageCount = _pagePreviews.Count > 0
|
||||
? _pagePreviews.Count
|
||||
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_pagePreviews = [];
|
||||
_pageCount = 0;
|
||||
}
|
||||
if (_loadedEnvelopeId != EnvelopeId && !_isLoadingEnvelopeForEdit)
|
||||
await LoadEnvelopeForEditAsync(EnvelopeId.Value);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
@@ -806,26 +699,6 @@
|
||||
_isPageReady = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsBrowser())
|
||||
return;
|
||||
|
||||
if (!IsEditMode && !_bootstrapApplied && !string.IsNullOrWhiteSpace(_pendingBootstrapToken))
|
||||
{
|
||||
var token = _pendingBootstrapToken;
|
||||
_pendingBootstrapToken = null;
|
||||
await TryApplyBootstrapAsync(token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_saveDraftAfterBootstrap && !_isSaving)
|
||||
{
|
||||
_saveDraftAfterBootstrap = false;
|
||||
await SaveDraftAndStayOnEditorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||
@@ -853,7 +726,7 @@
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
|
||||
_certificationType = envelope.CertificationType ?? (int)CertificationType.AdvancedElectronicSignature;
|
||||
_certificationType = envelope.CertificationType ?? CertificationType.AdvancedElectronicSignature;
|
||||
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
|
||||
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
|
||||
|
||||
@@ -869,12 +742,17 @@
|
||||
_receivers = envelopeReceivers
|
||||
.Select((er, index) => new ReceiverDraft(
|
||||
Guid.NewGuid(),
|
||||
er.ReceiverId,
|
||||
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
|
||||
er.Receiver?.EmailAddress ?? string.Empty,
|
||||
string.Empty,
|
||||
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||
.ToList();
|
||||
|
||||
var receiverDraftByReceiverId = _receivers
|
||||
.Where(r => r.ReceiverId.HasValue)
|
||||
.ToDictionary(r => r.ReceiverId!.Value, r => r);
|
||||
|
||||
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
|
||||
var colorByEmail = _receivers
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
|
||||
@@ -896,10 +774,17 @@
|
||||
? receiverColor
|
||||
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
|
||||
|
||||
var receiverDraft = receiverDraftByReceiverId.TryGetValue(element.ReceiverId, out var draft)
|
||||
? draft
|
||||
: _receivers.FirstOrDefault(r =>
|
||||
(!string.IsNullOrWhiteSpace(receiverEmail) && string.Equals(r.Email, receiverEmail, StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(r.FullName, receiverName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new SignatureFieldDraft(
|
||||
XPt: element.X * 72.0,
|
||||
YPt: element.Y * 72.0,
|
||||
Page: element.Page,
|
||||
ReceiverDraftId: receiverDraft?.Id ?? Guid.Empty,
|
||||
ReceiverName: receiverName,
|
||||
Color: color);
|
||||
})
|
||||
@@ -935,7 +820,6 @@
|
||||
_fileName = $"Envelope-{envelopeId}.pdf";
|
||||
_pendingReceiverForPlacement = null;
|
||||
_loadedEnvelopeId = envelopeId;
|
||||
_editingEnvelopeId = envelopeId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1135,6 +1019,7 @@
|
||||
XPt: xPt,
|
||||
YPt: yPt,
|
||||
Page: page1Based,
|
||||
ReceiverDraftId: _pendingReceiverForPlacement.Id,
|
||||
ReceiverName: _pendingReceiverForPlacement.FullName,
|
||||
Color: _pendingReceiverForPlacement.Color);
|
||||
|
||||
@@ -1237,9 +1122,8 @@
|
||||
|
||||
try
|
||||
{
|
||||
var previousEnvelopeId = EnvelopeId;
|
||||
var command = BuildCreateEnvelopeCommand(send: false);
|
||||
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
|
||||
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId}", command.EnvelopeId);
|
||||
var result = await EnvelopeService.UpsertAsync(command);
|
||||
|
||||
if (result is null)
|
||||
@@ -1247,16 +1131,9 @@
|
||||
|
||||
EnvelopeId = result.Id;
|
||||
_loadedEnvelopeId = result.Id;
|
||||
_editingEnvelopeId = result.Id;
|
||||
|
||||
if (navigateToDashboard)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
}
|
||||
else if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
|
||||
return result.Id;
|
||||
}
|
||||
@@ -1274,12 +1151,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task SaveDraftAndStayOnEditorAsync()
|
||||
{
|
||||
Logger.LogInformation("[SenderEditor] Trigger bootstrap draft save.");
|
||||
await SaveDraftCoreAsync(navigateToDashboard: false);
|
||||
}
|
||||
|
||||
// ── Send envelope ──
|
||||
async Task SendAsync()
|
||||
{
|
||||
@@ -1319,7 +1190,7 @@
|
||||
|
||||
// Warn if any receiver has no signature field, but don't block
|
||||
var receiversWithoutField = _receivers
|
||||
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
|
||||
.Where(r => !_signatureFields.Any(f => f.ReceiverDraftId == r.Id))
|
||||
.ToList();
|
||||
if (receiversWithoutField.Count > 0)
|
||||
{
|
||||
@@ -1334,21 +1205,10 @@
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
?? GetEnvelopeIdFromCurrentUri();
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
||||
{
|
||||
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
||||
?? throw new InvalidOperationException("Draft save did not return a result.");
|
||||
|
||||
envelopeId = saveResult.Id;
|
||||
EnvelopeId = saveResult.Id;
|
||||
_loadedEnvelopeId = saveResult.Id;
|
||||
_editingEnvelopeId = saveResult.Id;
|
||||
}
|
||||
throw new InvalidOperationException("Envelope id is missing for send.");
|
||||
|
||||
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
||||
?? throw new InvalidOperationException("Send did not return a result.");
|
||||
@@ -1379,13 +1239,10 @@
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
?? GetEnvelopeIdFromCurrentUri();
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (IsEditMode && envelopeId is null)
|
||||
throw new InvalidOperationException("Edit mode envelope id is missing.");
|
||||
if (envelopeId is null)
|
||||
throw new InvalidOperationException("Envelope id is missing.");
|
||||
|
||||
var docBase64 = _originalPdfBytes is { Length: > 0 }
|
||||
? Convert.ToBase64String(_originalPdfBytes)
|
||||
@@ -1394,7 +1251,7 @@
|
||||
var receiverCommands = _receivers.Select(receiver =>
|
||||
{
|
||||
var fields = _signatureFields
|
||||
.Where(f => f.ReceiverName == receiver.FullName)
|
||||
.Where(f => f.ReceiverDraftId == receiver.Id)
|
||||
.Select(f => new CreateEnvelopeFieldDto(
|
||||
X: f.XPt / 72.0,
|
||||
Y: f.YPt / 72.0,
|
||||
@@ -1473,28 +1330,6 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
int? GetEnvelopeIdFromCurrentUri()
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
if (string.IsNullOrWhiteSpace(uri.Query))
|
||||
return null;
|
||||
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (!query.TryGetValue("envelopeId", out var raw))
|
||||
return null;
|
||||
|
||||
return int.TryParse(raw.ToString(), out var id) && id > 0
|
||||
? id
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receiver popup ──
|
||||
void OpenAddReceiverPopup()
|
||||
{
|
||||
@@ -1656,18 +1491,49 @@
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
|
||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), null, fullName, email, phoneNumber,
|
||||
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
|
||||
CloseAddReceiverPopup();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task RemoveReceiverAsync(ReceiverDraft receiver)
|
||||
{
|
||||
if (_isSaving)
|
||||
return;
|
||||
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (envelopeId is int persistedEnvelopeId && persistedEnvelopeId > 0 && receiver.ReceiverId is int persistedReceiverId && persistedReceiverId > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnvelopeReceiverService.DeleteAsync(persistedEnvelopeId, persistedReceiverId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to remove receiver {ReceiverId} from envelope {EnvelopeId}", persistedReceiverId, persistedEnvelopeId);
|
||||
_saveErrorMessage = string.Format(
|
||||
L("SenderEditor_RemoveReceiverError", "Empfaenger konnte nicht entfernt werden: {0}"),
|
||||
ex.Message);
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_receivers.RemoveAll(r => r.Id == receiver.Id);
|
||||
_signatureFields.RemoveAll(f => f.ReceiverDraftId == receiver.Id);
|
||||
|
||||
if (_pendingReceiverForPlacement?.Id == receiver.Id)
|
||||
_pendingReceiverForPlacement = null;
|
||||
}
|
||||
|
||||
// ── Models ──
|
||||
record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
|
||||
record SignatureFieldDraft(double XPt, double YPt, int Page, Guid ReceiverDraftId, string ReceiverName, string Color);
|
||||
|
||||
record NormalisedCoords(double NormX, double NormY, int PageIndex);
|
||||
|
||||
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
|
||||
record ReceiverDraft(Guid Id, int? ReceiverId, string FullName, string Email, string PhoneNumber, string Color);
|
||||
|
||||
// ── Receiver colour palette (cycles when > 8 receivers) ──
|
||||
static readonly string[] ReceiverPalette =
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Client.Components.Sender
|
||||
@@ -20,7 +21,7 @@
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject ILogger<SenderPage> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@@ -315,12 +316,13 @@
|
||||
@foreach (var receiver in envelope.EnvelopeReceivers)
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
}
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
var hasReceiverEnvelopeUrl = CanOpenReceiverEnvelopeUrl(envelope, receiver);
|
||||
}
|
||||
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
||||
@if (isRejected)
|
||||
{
|
||||
@@ -349,27 +351,40 @@
|
||||
<strong style="color: #1f2937;">@receiver.Name</strong>
|
||||
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
||||
</div>
|
||||
<div class="sender-access-code">
|
||||
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
||||
<div class="sender-access-code__row">
|
||||
<input class="sender-access-code__value"
|
||||
<div class="sender-access-code">
|
||||
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
||||
<div class="sender-access-code__row">
|
||||
<input class="sender-access-code__value"
|
||||
value="@(hasAccessCode ? accessCode : "-")"
|
||||
readonly
|
||||
title="@L("AccessCode", "Access code")" />
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasAccessCode)"
|
||||
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
||||
title="@L("Copy", "Copy")">
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasAccessCode)"
|
||||
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
||||
title="@L("Copy", "Copy")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (_canUseReceiverEnvelopeUrl)
|
||||
{
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasReceiverEnvelopeUrl)"
|
||||
@onclick='() => OpenReceiverEnvelopeUrlAsync(envelope, receiver)'
|
||||
title="@L("Open", "Open")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M6.364 13.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 1 0v4a1.5 1.5 0 0 1-1.5 1.5h-8z"/>
|
||||
<path fill-rule="evenodd" d="M8.5 1a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-1 0V2.707L8.854 7.354a.5.5 0 1 1-.708-.708L12.793 2H9a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -544,6 +559,7 @@
|
||||
private string _activeTab = "active";
|
||||
private bool _isLoading = true;
|
||||
private bool _isLoggingOut = false;
|
||||
private bool _canUseReceiverEnvelopeUrl = false;
|
||||
private string? _errorMessage;
|
||||
private DxGrid? _grid;
|
||||
|
||||
@@ -603,6 +619,8 @@
|
||||
try { await ThemeService.LoadFromServerAsync(); }
|
||||
catch { /* swallow — default theme remains active */ }
|
||||
|
||||
_canUseReceiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlFeatureEnabledAsync();
|
||||
|
||||
// Load logo config — authenticated, so this should succeed
|
||||
var logos = await CustomImagesService.GetAsync();
|
||||
_appLogo = logos.App;
|
||||
@@ -629,7 +647,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
_allEnvelopes = await EnvelopeService.GetAsync(includeDocAndDocRes: false) ?? [];
|
||||
|
||||
// Split into active and completed based on effective status
|
||||
// (history-aware: rejected envelopes should not stay in active tab).
|
||||
@@ -726,29 +744,44 @@
|
||||
|
||||
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
|
||||
{
|
||||
var payload = new CreateEnvelopeBootstrapPayload(
|
||||
Title: result.Title,
|
||||
Message: result.Message,
|
||||
EnvelopeTypeId: result.EnvelopeTypeId,
|
||||
Language: result.Language,
|
||||
UseAccessCode: result.UseAccessCode,
|
||||
TFAEnabled: result.TFAEnabled,
|
||||
CertificationType: result.CertificationType,
|
||||
FinalEmailToCreator: result.FinalEmailToCreator,
|
||||
FinalEmailToReceivers: result.FinalEmailToReceivers,
|
||||
SendReminderEmails: result.SendReminderEmails,
|
||||
FirstReminderDays: result.FirstReminderDays,
|
||||
ReminderIntervalDays: result.ReminderIntervalDays,
|
||||
ExpiresWhenDays: result.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
|
||||
Receivers: result.Receivers
|
||||
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
|
||||
.ToList(),
|
||||
DocumentBytes: result.DocumentBytes,
|
||||
DocumentFileName: result.DocumentFileName);
|
||||
var docBase64 = result.DocumentBytes is { Length: > 0 }
|
||||
? Convert.ToBase64String(result.DocumentBytes)
|
||||
: null;
|
||||
|
||||
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
|
||||
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
|
||||
var command = new CreateEnvelopeCommand
|
||||
{
|
||||
EnvelopeTypeId = result.EnvelopeTypeId,
|
||||
Title = result.Title,
|
||||
Message = result.Message,
|
||||
Language = string.IsNullOrWhiteSpace(result.Language) ? "de-DE" : result.Language,
|
||||
UseAccessCode = result.UseAccessCode,
|
||||
TFAEnabled = result.TFAEnabled,
|
||||
CertificationType = result.CertificationType,
|
||||
FinalEmailToCreator = result.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = result.FinalEmailToReceivers,
|
||||
SendReminderEmails = result.SendReminderEmails,
|
||||
FirstReminderDays = result.FirstReminderDays,
|
||||
ReminderIntervalDays = result.ReminderIntervalDays,
|
||||
ExpiresWhenDays = result.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = result.ExpiresWarningWhenDays,
|
||||
Send = false,
|
||||
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
||||
Receivers = result.Receivers
|
||||
.Select(x => new CreateEnvelopeReceiverDto(x.EmailAddress, x.FullName, null, []))
|
||||
.ToList()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var envelope = await EnvelopeService.UpsertAsync(command)
|
||||
?? throw new InvalidOperationException("Envelope creation returned no result.");
|
||||
Navigation.NavigateTo($"/sender/editor?envelopeId={envelope.Id}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to create envelope from wizard.");
|
||||
_errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
void EditEnvelope()
|
||||
@@ -987,6 +1020,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task OpenReceiverEnvelopeUrlAsync(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
if (!_canUseReceiverEnvelopeUrl)
|
||||
return;
|
||||
|
||||
if (!CanOpenReceiverEnvelopeUrl(envelope, receiver))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var receiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlAsync(
|
||||
envelope.Uuid,
|
||||
receiver.Receiver!.Signature!);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(receiverEnvelopeUrl))
|
||||
return;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("open", receiverEnvelopeUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to open receiver envelope URL.");
|
||||
}
|
||||
}
|
||||
|
||||
static bool CanOpenReceiverEnvelopeUrl(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
=> !string.IsNullOrWhiteSpace(envelope.Uuid)
|
||||
&& !string.IsNullOrWhiteSpace(receiver.Receiver?.Signature);
|
||||
|
||||
void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
||||
{
|
||||
// Future: Add custom row coloring based on status if needed
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using EnvelopeGenerator.Server.Client.Services;
|
||||
using EnvelopeGenerator.Server.Client.Options;
|
||||
using MudBlazor.Services;
|
||||
using DevExpress.Blazor.Reporting;
|
||||
using DevExpress.XtraReports.Web.Extensions;
|
||||
using DevExpress.DataAccess.Web;
|
||||
using DevExpress.XtraReports.Services;
|
||||
using System.Globalization;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
@@ -15,6 +17,7 @@ CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
|
||||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// Named HttpClient for API calls (both for services and DevExpress components)
|
||||
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
|
||||
@@ -31,6 +34,8 @@ builder.Services.AddScoped(sp => new HttpClient
|
||||
// Configuration Options
|
||||
builder.Services.Configure<PdfViewerOptions>(opts =>
|
||||
builder.Configuration.GetSection(PdfViewerOptions.SectionName).Bind(opts));
|
||||
builder.Services.Configure<SignatureFieldParams>(opts =>
|
||||
builder.Configuration.GetSection(nameof(SignatureFieldParams)).Bind(opts));
|
||||
|
||||
// Business Services
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
@@ -49,7 +50,7 @@ public sealed record CreateEnvelopeBootstrapPayload(
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
using EnvelopeGenerator.Server.Client.Models;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
@@ -15,6 +16,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
|
||||
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private sealed record ReceiverEnvelopeUrlResponse(string Url);
|
||||
private sealed record ReceiverEnvelopeUrlEnabledResponse(bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the envelope receiver data for the given envelope key from the API.
|
||||
@@ -63,4 +66,59 @@ public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CreateEnvelopeReceiverResponse>(_jsonOptions, cancel);
|
||||
}
|
||||
|
||||
public async Task<string?> GetReceiverEnvelopeUrlAsync(string envelopeUuid, string receiverSignature, CancellationToken cancel = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(envelopeUuid) || string.IsNullOrWhiteSpace(receiverSignature))
|
||||
return null;
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/EnvelopeReceiver/receiver-envelope-url?envelopeUuid={Uri.EscapeDataString(envelopeUuid)}&receiverSignature={Uri.EscapeDataString(receiverSignature)}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlResponse>(_jsonOptions, cancel);
|
||||
return payload?.Url;
|
||||
}
|
||||
|
||||
public async Task<bool> GetReceiverEnvelopeUrlFeatureEnabledAsync(CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync("/api/EnvelopeReceiver/receiver-envelope-url/enabled", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlEnabledResponse>(_jsonOptions, cancel);
|
||||
return payload?.Enabled == true;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int envelopeId, int receiverId, CancellationToken cancel = default)
|
||||
{
|
||||
if (envelopeId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(envelopeId));
|
||||
if (receiverId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(receiverId));
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = QueryHelpers.AddQueryString("/api/EnvelopeReceiver", new Dictionary<string, string?>
|
||||
{
|
||||
["EnvelopeId"] = envelopeId.ToString(),
|
||||
["ReceiverId"] = receiverId.ToString()
|
||||
});
|
||||
|
||||
var response = await http.DeleteAsync(url, cancel);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to remove receiver. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(body) ? string.Empty : $" - {body}")}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
string? uuid = null,
|
||||
bool? onlyActive = null,
|
||||
bool? onlyCompleted = null,
|
||||
bool? includeDocAndDocRes = null,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var baseUrl = $"/api/Envelope";
|
||||
@@ -46,6 +47,10 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
{
|
||||
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
|
||||
}
|
||||
if (includeDocAndDocRes.HasValue)
|
||||
{
|
||||
queryParams["IncludeDocAndDocRes"] = includeDocAndDocRes.Value.ToString();
|
||||
}
|
||||
|
||||
var url = QueryHelpers.AddQueryString(baseUrl, queryParams);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
||||
<HeadOutlet />
|
||||
<!-- Apply saved theme immediately on page load — before WASM boots.
|
||||
@@ -37,6 +38,7 @@
|
||||
<script src="js/receiver-signature.js?v=9"></script>
|
||||
<script src="js/signflow-theme.js"></script>
|
||||
<script src="js/envelope-editor.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -23,27 +23,16 @@ namespace EnvelopeGenerator.Server.Controllers;
|
||||
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
|
||||
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// Erstellt eine neue Instanz des EnvelopeControllers.
|
||||
/// </remarks>
|
||||
/// <param name="Logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
|
||||
/// <param name="Mediator"></param>
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class EnvelopeController : ControllerBase
|
||||
public class EnvelopeController(ILogger<EnvelopeController> Logger, IMediator Mediator) : ControllerBase
|
||||
{
|
||||
public record DeleteEnvelopeRequest(string Reason);
|
||||
|
||||
private readonly ILogger<EnvelopeController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine neue Instanz des EnvelopeControllers.
|
||||
/// </summary>
|
||||
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
|
||||
/// <param name="mediator"></param>
|
||||
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
|
||||
/// </summary>
|
||||
@@ -58,8 +47,8 @@ public class EnvelopeController : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
|
||||
{
|
||||
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
|
||||
return result.Any() ? Ok(result) : NotFound();
|
||||
var result = await Mediator.Send(envelope.Authorize(User.GetId()));
|
||||
return Ok(result ?? []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,7 +63,7 @@ public class EnvelopeController : ControllerBase
|
||||
[HttpGet("doc-result")]
|
||||
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
|
||||
{
|
||||
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
|
||||
var envelopes = await Mediator.Send(query.Authorize(User.GetId()));
|
||||
var envelope = envelopes.FirstOrDefault();
|
||||
|
||||
if (envelope is null)
|
||||
@@ -100,22 +89,34 @@ public class EnvelopeController : ControllerBase
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||
{
|
||||
var res = await _mediator.Send(command.WithAuth(User.GetId()));
|
||||
var res = await Mediator.Send(command.WithAuth(User.GetId()));
|
||||
|
||||
if (res is null)
|
||||
{
|
||||
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
|
||||
Logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
else
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Reason"></param>
|
||||
public record DeleteEnvelopeRequest(string Reason);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> DeleteAsync([FromRoute] int id, [FromBody] DeleteEnvelopeRequest request)
|
||||
{
|
||||
var result = await _mediator.Send(new DeleteEnvelopeCommand
|
||||
var result = await Mediator.Send(new DeleteEnvelopeCommand
|
||||
{
|
||||
EnvelopeId = id,
|
||||
Reason = request?.Reason ?? string.Empty
|
||||
@@ -133,7 +134,7 @@ public class EnvelopeController : ControllerBase
|
||||
[HttpPost("finalize")]
|
||||
public async Task<IActionResult> FinalizeEnvelopeAsync([FromQuery] FinalizeEnvelopeCommand command)
|
||||
{
|
||||
await _mediator.Send(command.Authorized(User.GetId()));
|
||||
await Mediator.Send(command.Authorized(User.GetId()));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Server.Extensions;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Server.Options;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
@@ -24,32 +27,16 @@ namespace EnvelopeGenerator.Server.Controllers;
|
||||
/// <remarks>
|
||||
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// Konstruktor für den EnvelopeReceiverController.
|
||||
/// </remarks>
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
public class EnvelopeReceiverController : ControllerBase
|
||||
public class EnvelopeReceiverController(ILogger<EnvelopeReceiverController> Logger, IMediator Mediator, IMapper Mapper, IEnvelopeExecutor EnvelopeExecutor, IEnvelopeReceiverExecutor ER_Executor, IDocumentExecutor DocExecutor, IOptions<ConnectionString> csOpt, IOptionsMonitor<SenderFeatureAccessOptions> senderFeatAccessOpt) : ControllerBase
|
||||
{
|
||||
private readonly ILogger<EnvelopeReceiverController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IEnvelopeExecutor _envelopeExecutor;
|
||||
private readonly IEnvelopeReceiverExecutor _erExecutor;
|
||||
private readonly IDocumentExecutor _documentExecutor;
|
||||
private readonly string _cnnStr;
|
||||
|
||||
/// <summary>
|
||||
/// Konstruktor für den EnvelopeReceiverController.
|
||||
/// </summary>
|
||||
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
_mapper = mapper;
|
||||
_envelopeExecutor = envelopeExecutor;
|
||||
_erExecutor = erExecutor;
|
||||
_documentExecutor = documentExecutor;
|
||||
_cnnStr = csOpt.Value.Value;
|
||||
}
|
||||
private readonly string _cnnStr = csOpt.Value.Value;
|
||||
private readonly IOptionsMonitor<SenderFeatureAccessOptions> SenderFeatAccessOpt = senderFeatAccessOpt;
|
||||
|
||||
/// <summary>
|
||||
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
|
||||
@@ -69,7 +56,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
{
|
||||
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
|
||||
|
||||
var result = await _mediator.Send(envelopeReceiver);
|
||||
var result = await Mediator.Send(envelopeReceiver);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
@@ -84,7 +71,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
[HttpGet("{envelopeKey}")]
|
||||
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
|
||||
{
|
||||
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
|
||||
var er = await Mediator.Send(new ReadEnvelopeReceiverQuery()
|
||||
{
|
||||
Key = envelopeKey
|
||||
}, cancel);
|
||||
@@ -108,10 +95,53 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
[HttpGet("salute")]
|
||||
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
|
||||
{
|
||||
var name = await _mediator.Send(receiver);
|
||||
var name = await Mediator.Send(receiver);
|
||||
return name is null ? NotFound() : Ok(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="envelopeUuid"></param>
|
||||
/// <param name="receiverSignature"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpGet("receiver-envelope-url")]
|
||||
public IActionResult GetReceiverEnvelopeUrl([FromQuery] string envelopeUuid, [FromQuery] string receiverSignature)
|
||||
{
|
||||
if (!CanUseReceiverEnvelopeUrlFeature())
|
||||
return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(envelopeUuid) || string.IsNullOrWhiteSpace(receiverSignature))
|
||||
return BadRequest("Envelope uuid and receiver signature are required.");
|
||||
|
||||
var envelopeKey = (envelopeUuid, receiverSignature).ToEnvelopeKey();
|
||||
var absoluteUrl = $"{Request.Scheme}://{Request.Host}/envelope/{Uri.EscapeDataString(envelopeKey)}";
|
||||
|
||||
return Ok(new { url = absoluteUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpGet("receiver-envelope-url/enabled")]
|
||||
public IActionResult GetReceiverEnvelopeUrlFeatureEnabled()
|
||||
{
|
||||
return Ok(new { enabled = CanUseReceiverEnvelopeUrlFeature() });
|
||||
}
|
||||
|
||||
private bool CanUseReceiverEnvelopeUrlFeature()
|
||||
{
|
||||
var senderEmail = User.GetEmail();
|
||||
if (string.IsNullOrWhiteSpace(senderEmail))
|
||||
return false;
|
||||
|
||||
var allowedEmails = SenderFeatAccessOpt.CurrentValue.AllowedReceiverEnvelopeUrlEmails;
|
||||
return allowedEmails.Any(email => string.Equals(email?.Trim(), senderEmail, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns recent receiver suggestions for the authenticated sender.
|
||||
/// </summary>
|
||||
@@ -122,7 +152,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
[HttpGet("recent-suggestions")]
|
||||
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
|
||||
{
|
||||
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
|
||||
var result = await Mediator.Send(query.ForSender(User.GetUsername()), cancel);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -169,7 +199,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||
{
|
||||
#region Create Envelope
|
||||
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
|
||||
var envelope = await EnvelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
|
||||
#endregion
|
||||
|
||||
#region Add receivers
|
||||
@@ -178,7 +208,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
|
||||
foreach (var receiver in request.Receivers)
|
||||
{
|
||||
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
|
||||
var envelopeReceiver = await ER_Executor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
|
||||
|
||||
if (envelopeReceiver is null)
|
||||
unsentReceivers.Add(receiver);
|
||||
@@ -186,13 +216,13 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
sentReceivers.Add(envelopeReceiver);
|
||||
}
|
||||
|
||||
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
|
||||
var res = Mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
|
||||
res.UnsentReceivers = unsentReceivers;
|
||||
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
|
||||
res.SentReceiver = Mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
|
||||
#endregion
|
||||
|
||||
#region Add document
|
||||
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
|
||||
var document = await DocExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
|
||||
|
||||
if (document is null)
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
|
||||
@@ -229,7 +259,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
{
|
||||
bool outSuccess = reader.GetBoolean(0);
|
||||
if (!outSuccess)
|
||||
_logger.LogWarning(
|
||||
Logger.LogWarning(
|
||||
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
|
||||
document.Id, rcv.Id, sign.Page);
|
||||
}
|
||||
@@ -261,7 +291,7 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
{
|
||||
bool outSuccess = reader.GetBoolean(0);
|
||||
if (!outSuccess)
|
||||
_logger.LogWarning(
|
||||
Logger.LogWarning(
|
||||
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
|
||||
envelope.Uuid);
|
||||
}
|
||||
@@ -292,4 +322,18 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> RemoveAsync([FromQuery] RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||
{
|
||||
request = request.Authenticated(User.GetId());
|
||||
await Mediator.Send(request, cancel);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sends envelope notifications, optionally with randomly generated payloads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="NotificationController"/> class.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class NotificationController(IEnvelopeNotificationService notificationService) : ControllerBase
|
||||
{
|
||||
private const string FallbackEmail = "htek0100@gmail.com";
|
||||
|
||||
/// <summary>
|
||||
/// Sends the supplied document signed notification.
|
||||
/// </summary>
|
||||
[HttpPost("signed")]
|
||||
public async Task<IActionResult> SendSigned(
|
||||
[FromBody] DocumentSignedNotification notification,
|
||||
[FromQuery] string? email,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
await notificationService.Notify(NormalizeEmail(email), notification, cancel);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a randomly generated document signed notification.
|
||||
/// </summary>
|
||||
[HttpPost("signed/random")]
|
||||
public async Task<IActionResult> SendSignedRandom(
|
||||
[FromQuery] string? email,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
await notificationService.Notify(NormalizeEmail(email), new DocumentSignedNotification
|
||||
{
|
||||
DocumentTitle = $"Dokument {Random.Shared.Next(1000, 9999)}",
|
||||
NameReceiver = $"Empfänger {Random.Shared.Next(1, 100)}",
|
||||
IsReadAndConfirm = Random.Shared.Next(2) == 0,
|
||||
}, cancel);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the supplied document received notification.
|
||||
/// </summary>
|
||||
[HttpPost("received")]
|
||||
public async Task<IActionResult> SendReceived(
|
||||
[FromBody] DocumentReceivedNotification notification,
|
||||
[FromQuery] string? email,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
await notificationService.Notify(NormalizeEmail(email), notification, cancel);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a randomly generated document received notification.
|
||||
/// </summary>
|
||||
[HttpPost("received/random")]
|
||||
public async Task<IActionResult> SendReceivedRandom(
|
||||
[FromQuery] string? email,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
await notificationService.Notify(NormalizeEmail(email), new DocumentReceivedNotification
|
||||
{
|
||||
DocumentTitle = $"Dokument {Random.Shared.Next(1000, 9999)}",
|
||||
NameReceiver = $"Empfänger {Random.Shared.Next(1, 100)}",
|
||||
NameSender = $"Absender {Random.Shared.Next(1, 100)}",
|
||||
IsReadAndConfirm = Random.Shared.Next(2) == 0,
|
||||
AdvancedSignature = Random.Shared.Next(2) == 0,
|
||||
Origin = new Uri($"{Request.Scheme}://{Request.Host}"),
|
||||
EnvelopeUuid = Guid.NewGuid().ToString(),
|
||||
ReceiverSignature = Guid.NewGuid().ToString(),
|
||||
Message = $"Testnachricht {Random.Shared.Next(1000, 9999)}"
|
||||
}, cancel);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static string NormalizeEmail(string? email)
|
||||
=> string.IsNullOrWhiteSpace(email) ? FallbackEmail : email;
|
||||
}
|
||||
@@ -4,13 +4,17 @@ using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
|
||||
public class PdfRenderController(IPdfPageImageRenderer renderer, IMemoryCache memoryCache) : ControllerBase
|
||||
{
|
||||
private static readonly TimeSpan RenderCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
|
||||
[HttpPost("pages")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
@@ -19,7 +23,19 @@ public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBas
|
||||
if (request?.PdfBytes is not { Length: > 0 } bytes)
|
||||
return BadRequest("Missing PDF bytes.");
|
||||
|
||||
var cacheKey = BuildCacheKey(bytes);
|
||||
if (memoryCache.TryGetValue(cacheKey, out IReadOnlyList<PdfPageImageDto>? cachedPages) && cachedPages is not null)
|
||||
return Ok(cachedPages);
|
||||
|
||||
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
|
||||
memoryCache.Set(cacheKey, pages, RenderCacheDuration);
|
||||
|
||||
return Ok(pages);
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(byte[] pdfBytes)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(pdfBytes));
|
||||
return $"pdf-render:pages:v1:dpi110:{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>EnvelopeGenerator.Server</Product>
|
||||
<Version>1.7.0-beta</Version>
|
||||
<FileVersion>1.7.0.0</FileVersion>
|
||||
<AssemblyVersion>1.7.0.0</AssemblyVersion>
|
||||
<Version>1.8.0-beta</Version>
|
||||
<FileVersion>1.8.0.0</FileVersion>
|
||||
<AssemblyVersion>1.8.0.0</AssemblyVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.22" />
|
||||
<PackageReference Include="MudBlazor" Version="8.0.0" />
|
||||
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace EnvelopeGenerator.Server.Options;
|
||||
|
||||
public sealed class SenderFeatureAccessOptions
|
||||
{
|
||||
public const string SectionName = "SenderFeatureAccess";
|
||||
|
||||
public List<string> AllowedReceiverEnvelopeUrlEmails { get; set; } = [];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using DigitalData.Core.Application;
|
||||
using DigitalData.EmailProfilerDispatcher;
|
||||
using DigitalData.UserManager.DependencyInjection;
|
||||
using EnvelopeGenerator.Application;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Infrastructure;
|
||||
@@ -19,6 +20,7 @@ using EnvelopeGenerator.Server.Client.Services;
|
||||
using EnvelopeGenerator.Server.Components;
|
||||
using EnvelopeGenerator.Server.Models;
|
||||
using EnvelopeGenerator.Server.Options;
|
||||
using EnvelopeGenerator.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
@@ -30,6 +32,7 @@ using NLog;
|
||||
using NLog.Web;
|
||||
using Scalar.AspNetCore;
|
||||
using System.Globalization;
|
||||
using MudBlazor.Services;
|
||||
|
||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||
@@ -58,6 +61,7 @@ try
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// Add API Controllers
|
||||
builder.Services.AddControllers()
|
||||
@@ -298,6 +302,7 @@ try
|
||||
|
||||
// Cache options
|
||||
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
|
||||
builder.Services.Configure<SenderFeatureAccessOptions>(config.GetSection(SenderFeatureAccessOptions.SectionName));
|
||||
|
||||
// Distributed Cache - SQL Server
|
||||
builder.Services.AddDistributedSqlServerCache(options =>
|
||||
@@ -391,6 +396,8 @@ try
|
||||
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.PdfViewerOptions>(
|
||||
builder.Configuration.GetSection("PdfViewerOptions"));
|
||||
|
||||
builder.Services.AddScoped<IRequestContext, RequestContext>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
deferredProvider.Factory = () => app.Services;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="HttpContextAccessor"></param>
|
||||
public sealed class RequestContext(
|
||||
IHttpContextAccessor HttpContextAccessor) : IRequestContext
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string? Origin =>
|
||||
HttpContextAccessor.HttpContext?
|
||||
.Request.Headers.Origin.ToString();
|
||||
}
|
||||
@@ -58,8 +58,12 @@
|
||||
"MainCanvasEnableHiDPI": true,
|
||||
"ZoomStepPercentage": 5
|
||||
},
|
||||
"SignatureFieldParams": {
|
||||
"Width": 1.77,
|
||||
"Height": 1.96
|
||||
},
|
||||
"DocumentService": {
|
||||
"BaseUrl": "https://localhost:7186"
|
||||
"BaseUrl": "http://172.24.12.39:9393"
|
||||
},
|
||||
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
|
||||
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
|
||||
@@ -208,6 +212,9 @@
|
||||
"TableName": "TBDD_CACHE"
|
||||
}
|
||||
},
|
||||
"SenderFeatureAccess": {
|
||||
"AllowedReceiverEnvelopeUrlEmails": ["h.tek@digitaldata.works"]
|
||||
},
|
||||
"MainPageTitle": null,
|
||||
"AnnotationParams": {
|
||||
"Background": {
|
||||
|
||||
@@ -14,5 +14,9 @@
|
||||
"RenderingOpacity": 0.85,
|
||||
"ThumbnailRenderDelay": 50,
|
||||
"ZoomStepPercentage": 5
|
||||
},
|
||||
"SignatureFieldParams": {
|
||||
"Width": 1.77,
|
||||
"Height": 1.96
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +989,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.sender-toolbar-action-btn--compact {
|
||||
padding: 0.45rem 0.7rem;
|
||||
padding: 0.28rem 0.42rem;
|
||||
}
|
||||
|
||||
.sender-receivers-panel {
|
||||
@@ -1208,23 +1208,68 @@ body.resizing {
|
||||
.sender-receivers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sender-receivers-list__add-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
min-height: 30px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
margin-right: 0.28rem;
|
||||
}
|
||||
|
||||
.sender-receivers-list__add-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sender-receivers-list__count {
|
||||
position: absolute;
|
||||
top: -0.45rem;
|
||||
right: -0.45rem;
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.sender-receiver-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 220px;
|
||||
gap: 0.35rem;
|
||||
min-width: 190px;
|
||||
max-width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.28rem 0.4rem;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(126, 34, 206, 0.12);
|
||||
border-left: 3px solid var(--receiver-color, #7c3aed);
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.sender-receiver-chip__action {
|
||||
min-width: auto;
|
||||
min-height: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sender-receiver-chip__action svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.sender-receiver-chip__place-btn--idle {
|
||||
color: var(--receiver-color, #7c3aed);
|
||||
border-color: var(--receiver-color, #7c3aed);
|
||||
@@ -1236,6 +1281,17 @@ body.resizing {
|
||||
border-color: var(--receiver-color, #7c3aed);
|
||||
}
|
||||
|
||||
.sender-receiver-chip__remove-btn {
|
||||
color: #b91c1c;
|
||||
border-color: rgba(185, 28, 28, 0.35);
|
||||
}
|
||||
|
||||
.sender-receiver-chip__remove-btn:hover {
|
||||
background: #b91c1c;
|
||||
color: #ffffff;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.sender-editor-action-inner {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1505,8 +1561,9 @@ body.resizing {
|
||||
}
|
||||
|
||||
.sender-receiver-chip__email {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1116,57 +1116,71 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sender-create-wizard__progress {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
.sender-create-wizard__stepper-shell {
|
||||
border: 1px solid rgba(126, 34, 206, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(126, 34, 206, 0.04);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__progress-track {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(126, 34, 206, 0.18);
|
||||
z-index: 0;
|
||||
.sender-create-wizard__stepper {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sender-create-wizard__step {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.sender-create-wizard__stepper .mud-step {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sender-create-wizard__step-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
.sender-create-wizard__stepper .mud-stepper-actions,
|
||||
.sender-create-wizard__stepper .mud-stepper-action {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper .mud-step-label {
|
||||
color: #1f2937;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper .mud-step-content {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper .mud-stepper-vertical .mud-stepper-step {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper .mud-step-icon {
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(126, 34, 206, 0.25);
|
||||
}
|
||||
|
||||
.sender-create-wizard__step--active .sender-create-wizard__step-index {
|
||||
.sender-create-wizard__stepper .mud-step-completed .mud-step-icon,
|
||||
.sender-create-wizard__stepper .mud-step-current .mud-step-icon {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper .mud-step-current .mud-step-icon {
|
||||
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
|
||||
box-shadow: 0 0 0 3px rgba(126, 34, 206, 0.15);
|
||||
}
|
||||
|
||||
.sender-create-wizard__step--done .sender-create-wizard__step-index {
|
||||
color: #ffffff;
|
||||
.sender-create-wizard__stepper .mud-step-completed .mud-step-icon {
|
||||
background: #2a5298;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stepper-description {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: #64748b;
|
||||
margin-top: 0.2rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__step-content {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__meta {
|
||||
|
||||
@@ -215,7 +215,7 @@ public static class Extensions
|
||||
Message = faker.Lorem.Paragraph(faker.Random.Number(2, 5)),
|
||||
TfaEnabled = tfaEnabled,
|
||||
AddedWhen = DateTime.Now,
|
||||
CertificationType = (int)CertificationType.AdvancedElectronicSignature,
|
||||
CertificationType = CertificationType.AdvancedElectronicSignature,
|
||||
UseAccessCode = false,
|
||||
ContractType = (int)ContractType.Contract,
|
||||
Language = "de",
|
||||
|
||||
@@ -71,10 +71,12 @@
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
|
||||
<Compile Remove="Application\Crypt\**\*.cs" />
|
||||
<Compile Remove="Infrastructure\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using EnvelopeGenerator.Infrastructure.Notification;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace EnvelopeGenerator.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class MailTemplatePlaceholderDbTests
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = new(@"\[[A-Z0-9_]+\]", RegexOptions.Compiled);
|
||||
|
||||
private static IEnumerable<TestCaseData> DbTemplateCases()
|
||||
{
|
||||
foreach (var template in ReadTemplates())
|
||||
{
|
||||
yield return new TestCaseData(template)
|
||||
{
|
||||
TestName = $"Template_{template.Name}_{template.LangCode}_{template.Guid}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Database_contains_templates()
|
||||
{
|
||||
Assert.That(ReadTemplates(), Is.Not.Empty);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(DbTemplateCases))]
|
||||
public void Every_placeholder_in_every_db_template_is_replaced(TemplateRow template)
|
||||
{
|
||||
var data = new TemplatePlaceholders();
|
||||
|
||||
var subject = template.Subject.ReplacePlaceholders(data);
|
||||
var body = template.Body.ReplacePlaceholders(data);
|
||||
|
||||
var originalTokens = PlaceholderPattern
|
||||
.Matches(template.Subject + template.Body)
|
||||
.Select(m => m.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Unresolved(subject), Is.Empty,
|
||||
$"'{template.Name}' subject has unresolved placeholders.");
|
||||
Assert.That(Unresolved(body), Is.Empty,
|
||||
$"'{template.Name}' body has unresolved placeholders.");
|
||||
|
||||
foreach (var token in originalTokens)
|
||||
{
|
||||
Assert.That(subject + body, Does.Contain(Expected(token)),
|
||||
$"'{template.Name}' placeholder {token} was not replaced with its value.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string[] Unresolved(string text)
|
||||
=> PlaceholderPattern.Matches(text)
|
||||
.Select(m => m.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
private static string Expected(string token) => $"__{token.Trim('[', ']')}__";
|
||||
|
||||
private static List<TemplateRow> ReadTemplates()
|
||||
{
|
||||
const string sql = "SELECT GUID, NAME, SUBJECT, BODY, LANG_CODE FROM TBSIG_EMAIL_TEMPLATE";
|
||||
|
||||
using var connection = new SqlConnection(ReadConnectionString());
|
||||
connection.Open();
|
||||
|
||||
using var command = new SqlCommand(sql, connection);
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var templates = new List<TemplateRow>();
|
||||
while (reader.Read())
|
||||
{
|
||||
templates.Add(new TemplateRow(
|
||||
reader.GetInt32(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
|
||||
reader.IsDBNull(4) ? string.Empty : reader.GetString(4)));
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
private static string ReadConnectionString()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EnvelopeGenerator.sln")))
|
||||
directory = directory.Parent;
|
||||
|
||||
if (directory is null)
|
||||
throw new InvalidOperationException("Solution root (EnvelopeGenerator.sln) was not found.");
|
||||
|
||||
var settingsPath = Path.Combine(
|
||||
directory.FullName,
|
||||
"EnvelopeGenerator.Server",
|
||||
"EnvelopeGenerator.Server",
|
||||
"appsettings.json");
|
||||
|
||||
using var stream = File.OpenRead(settingsPath);
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
});
|
||||
|
||||
return document.RootElement
|
||||
.GetProperty("ConnectionStrings")
|
||||
.GetProperty("Default")
|
||||
.GetString()
|
||||
?? throw new InvalidOperationException($"ConnectionStrings:Default is missing in '{settingsPath}'.");
|
||||
}
|
||||
|
||||
public sealed record TemplateRow(int Guid, string Name, string Subject, string Body, string LangCode);
|
||||
|
||||
/// <summary>
|
||||
/// One property per placeholder used by any row in TBSIG_EMAIL_TEMPLATE.
|
||||
/// The sentinel value equals the placeholder name, so a wrong mapping is detected.
|
||||
/// </summary>
|
||||
private sealed class TemplatePlaceholders
|
||||
{
|
||||
public string DocumentProcess { get; set; } = "__DOCUMENT_PROCESS__";
|
||||
public string DocumentTitle { get; set; } = "__DOCUMENT_TITLE__";
|
||||
public string LinkToDocumentText { get; set; } = "__LINK_TO_DOCUMENT_TEXT__";
|
||||
public string LinkToDocument { get; set; } = "__LINK_TO_DOCUMENT__";
|
||||
public string Message { get; set; } = "__MESSAGE__";
|
||||
public string NamePortal { get; set; } = "__NAME_PORTAL__";
|
||||
public string NameReceiver { get; set; } = "__NAME_RECEIVER__";
|
||||
public string NameSender { get; set; } = "__NAME_SENDER__";
|
||||
public string SignatureType { get; set; } = "__SIGNATURE_TYPE__";
|
||||
public string Reason { get; set; } = "__REASON__";
|
||||
public string ReceiverAction { get; set; } = "__RECEIVER_ACTION__";
|
||||
public string FinalAction { get; set; } = "__FINAL_ACTION__";
|
||||
public string FinalStatus { get; set; } = "__FINAL_STATUS__";
|
||||
public string DocumentAccessCode { get; set; } = "__DOCUMENT_ACCESS_CODE__";
|
||||
public string TfaExpiration { get; set; } = "__TFA_EXPIRATION__";
|
||||
public string TfaQrCode { get; set; } = "__TFA_QR_CODE__";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
namespace EnvelopeGenerator.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class ReplacePlaceholderExtensionsTests
|
||||
{
|
||||
#region Naming
|
||||
|
||||
private static IEnumerable<TestCaseData> NamingCases()
|
||||
{
|
||||
yield return Naming<PersonModel>("[FIRST_NAME]");
|
||||
yield return Naming<PersonModel>("[LAST_NAME]");
|
||||
yield return Naming<IdModel>("[ID]");
|
||||
yield return Naming<UpperIdModel>("[ID]");
|
||||
yield return Naming<AcronymModel>("[URL_VALUE]");
|
||||
yield return Naming<AcronymModel>("[HTTP_STATUS_CODE]");
|
||||
yield return Naming<AcronymModel>("[XML_HTTP_REQUEST]");
|
||||
yield return Naming<AcronymModel>("[OTP]");
|
||||
yield return Naming<DigitModel>("[FIELD1]");
|
||||
yield return Naming<DigitModel>("[VALUE2_TEXT]");
|
||||
yield return Naming<DigitModel>("[A1_B2]");
|
||||
yield return Naming<DigitModel>("[NAME2]");
|
||||
yield return Naming<MyUrl2ValueModel>("[MY_URL2_VALUE]");
|
||||
yield return Naming<SingleLetterModel>("[X]");
|
||||
yield return Naming<LowerCaseModel>("[FIRST_NAME]");
|
||||
yield return Naming<SnakeCaseModel>("[FIRST_NAME]");
|
||||
yield return Naming<TfaModel>("[TFA_EXPIRATION]");
|
||||
}
|
||||
|
||||
private static TestCaseData Naming<T>(string placeholder) where T : new()
|
||||
=> new(new T(), placeholder)
|
||||
{
|
||||
TestName = $"Placeholder_{typeof(T).Name}_{placeholder.Trim('[', ']')}"
|
||||
};
|
||||
|
||||
[TestCaseSource(nameof(NamingCases))]
|
||||
public void Property_names_are_converted_to_upper_snake_case(object data, string placeholder)
|
||||
{
|
||||
var result = placeholder.ReplacePlaceholders(data);
|
||||
|
||||
Assert.That(result, Is.EqualTo("v"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("tr-TR")]
|
||||
public void Placeholder_names_are_culture_invariant()
|
||||
{
|
||||
var result = "[INFO]".ReplacePlaceholders(new LowerCaseInfoModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("v"), "Turkish casing would produce [\u0130NFO]");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic replacement
|
||||
|
||||
[Test]
|
||||
public void Replaces_single_placeholder()
|
||||
{
|
||||
var result = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "Ada" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ada"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Replaces_multiple_placeholders()
|
||||
{
|
||||
var data = new PersonModel { FirstName = "Ada", LastName = "Lovelace" };
|
||||
|
||||
var result = "[FIRST_NAME] [LAST_NAME]".ReplacePlaceholders(data);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ada Lovelace"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Replaces_all_repeated_occurrences()
|
||||
{
|
||||
var data = new PersonModel { FirstName = "Ada" };
|
||||
|
||||
var result = "[FIRST_NAME]-[FIRST_NAME]-[FIRST_NAME]".ReplacePlaceholders(data);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ada-Ada-Ada"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Leaves_unknown_placeholder_untouched()
|
||||
{
|
||||
var result = "[UNKNOWN_TOKEN]".ReplacePlaceholders(new PersonModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("[UNKNOWN_TOKEN]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_template_unchanged_when_it_has_no_placeholders()
|
||||
{
|
||||
var result = "Hello world".ReplacePlaceholders(new PersonModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("Hello world"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_empty_string_for_empty_template()
|
||||
{
|
||||
var result = string.Empty.ReplacePlaceholders(new PersonModel());
|
||||
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_template_unchanged_when_data_has_no_readable_properties()
|
||||
{
|
||||
var result = "[ANYTHING]".ReplacePlaceholders(new object());
|
||||
|
||||
Assert.That(result, Is.EqualTo("[ANYTHING]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Matching_is_case_sensitive()
|
||||
{
|
||||
var result = "[name]|[Name]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("[name]|[Name]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placeholder_without_brackets_is_untouched()
|
||||
{
|
||||
var result = "NAME Receiver".ReplacePlaceholders(new PersonNameModel { Name = "v" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("NAME Receiver"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Double_brackets_leave_the_outer_brackets()
|
||||
{
|
||||
var result = "[[NAME]]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("[v]"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Values
|
||||
|
||||
[Test]
|
||||
public void Null_value_is_replaced_with_empty_string()
|
||||
{
|
||||
var result = "[NAME]".ReplacePlaceholders(new { Name = (string?)null });
|
||||
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_ToString_is_used()
|
||||
{
|
||||
var result = "[VALUE]".ReplacePlaceholders(new { Value = new CustomValue() });
|
||||
|
||||
Assert.That(result, Is.EqualTo("custom"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Primitive_values_use_ToString()
|
||||
{
|
||||
var result = "[COUNT]|[ENABLED]".ReplacePlaceholders(new { Count = 3, Enabled = true });
|
||||
|
||||
Assert.That(result, Is.EqualTo("3|True"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Regex_like_characters_are_inserted_literally()
|
||||
{
|
||||
var result = "[PATTERN]".ReplacePlaceholders(new { Pattern = @"$1 {0} \ $&" });
|
||||
|
||||
Assert.That(result, Is.EqualTo(@"$1 {0} \ $&"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Value_containing_its_own_placeholder_is_not_expanded_recursively()
|
||||
{
|
||||
var result = "[NAME]".ReplacePlaceholders(new EchoNameModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("[NAME]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unicode_value_is_preserved()
|
||||
{
|
||||
var result = "[NAME]".ReplacePlaceholders(new PersonNameModel { Name = "Z\u00fcrich \ud83d\ude00" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("Z\u00fcrich \ud83d\ude00"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property discovery
|
||||
|
||||
[Test]
|
||||
public void Public_readable_property_is_replaced()
|
||||
{
|
||||
var result = "[SHOWN]".ReplacePlaceholders(new DiscoveryModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("shown"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_only_property_is_replaced()
|
||||
{
|
||||
var result = "[READ_ONLY]".ReplacePlaceholders(new DiscoveryModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("computed"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Static_private_write_only_and_indexer_properties_are_ignored()
|
||||
{
|
||||
const string template = "[HIDDEN]|[STATIC]|[WRITE_ONLY]|[ITEM]";
|
||||
|
||||
var result = template.ReplacePlaceholders(new DiscoveryModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo(template));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Inherited_public_property_is_replaced()
|
||||
{
|
||||
var result = "[ID]|[NAME]".ReplacePlaceholders(new DerivedModel());
|
||||
|
||||
Assert.That(result, Is.EqualTo("base|derived"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_properties_are_replaced()
|
||||
{
|
||||
var result = "[FIRST_NAME]/[AGE]".ReplacePlaceholders(new PersonRecord("Ada", 36));
|
||||
|
||||
Assert.That(result, Is.EqualTo("Ada/36"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Struct_data_is_supported()
|
||||
{
|
||||
var result = "[NAME]".ReplacePlaceholders(new StructModel { Name = "struct" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("struct"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Anonymous_type_is_supported()
|
||||
{
|
||||
var data = new { Name = "anon", Age = 42 };
|
||||
|
||||
var result = "[NAME]|[AGE]".ReplacePlaceholders(data);
|
||||
|
||||
Assert.That(result, Is.EqualTo("anon|42"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Generic_type_is_supported_for_reference_type()
|
||||
{
|
||||
var result = "[VALUE]".ReplacePlaceholders(new Box<string> { Value = "boxed" });
|
||||
|
||||
Assert.That(result, Is.EqualTo("boxed"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Generic_type_is_supported_for_value_type()
|
||||
{
|
||||
var result = "[VALUE]".ReplacePlaceholders(new Box<int> { Value = 5 });
|
||||
|
||||
Assert.That(result, Is.EqualTo("5"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache and thread safety
|
||||
|
||||
[Test]
|
||||
public void Cached_metadata_uses_values_of_current_instance()
|
||||
{
|
||||
var first = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "first" });
|
||||
var second = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "second" });
|
||||
|
||||
Assert.That(new[] { first, second }, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Two_types_with_same_property_name_are_resolved_independently()
|
||||
{
|
||||
var a = "[NAME]".ReplacePlaceholders(new TypeA());
|
||||
var b = "[NAME]".ReplacePlaceholders(new TypeB());
|
||||
|
||||
Assert.That(new[] { a, b }, Is.EqualTo(new[] { "a", "b" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Concurrent_first_calls_are_thread_safe()
|
||||
{
|
||||
var failures = 0;
|
||||
|
||||
Parallel.For(0, 500, i =>
|
||||
{
|
||||
var expected = $"n{i}";
|
||||
var actual = "[NAME]".ReplacePlaceholders(new ConcurrentModel { Name = expected });
|
||||
|
||||
if (actual != expected)
|
||||
Interlocked.Increment(ref failures);
|
||||
});
|
||||
|
||||
Assert.That(failures, Is.Zero);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real DTO
|
||||
|
||||
[Test]
|
||||
public void Document_received_notification_is_fully_replaced()
|
||||
{
|
||||
var data = new DocumentReceivedNotification
|
||||
{
|
||||
DocumentTitle = "Vertrag",
|
||||
NameReceiver = "Max",
|
||||
NameSender = "Erika",
|
||||
IsReadAndConfirm = true,
|
||||
AdvancedSignature = false,
|
||||
LinkToDocument = "https://x.io/a",
|
||||
Message = "Bitte",
|
||||
NamePortal = "Portal"
|
||||
};
|
||||
|
||||
const string template =
|
||||
"[NAME_RECEIVER]|[NAME_SENDER]|[SIGNATURE_TYPE]|[DOCUMENT_TITLE]|[MESSAGE]|[NAME_PORTAL]|[LINK_TO_DOCUMENT]|[LINK_TO_DOCUMENT_TEXT]";
|
||||
|
||||
var result = template.ReplacePlaceholders(data);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Max|Erika|Signieren|Vertrag|Bitte|Portal|https://x.io/a|https://x.io/a"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Models
|
||||
|
||||
private sealed class PersonModel
|
||||
{
|
||||
public string FirstName { get; set; } = "v";
|
||||
public string LastName { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class PersonNameModel
|
||||
{
|
||||
public string Name { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class IdModel
|
||||
{
|
||||
public string Id { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class UpperIdModel
|
||||
{
|
||||
public string ID { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class AcronymModel
|
||||
{
|
||||
public string URLValue { get; set; } = "v";
|
||||
public string HTTPStatusCode { get; set; } = "v";
|
||||
public string XMLHttpRequest { get; set; } = "v";
|
||||
public string OTP { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class DigitModel
|
||||
{
|
||||
public string Field1 { get; set; } = "v";
|
||||
public string Value2Text { get; set; } = "v";
|
||||
public string A1B2 { get; set; } = "v";
|
||||
public string Name2 { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class MyUrl2ValueModel
|
||||
{
|
||||
public string MyURL2Value { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class SingleLetterModel
|
||||
{
|
||||
public string X { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class LowerCaseModel
|
||||
{
|
||||
public string firstName { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class LowerCaseInfoModel
|
||||
{
|
||||
public string info { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class SnakeCaseModel
|
||||
{
|
||||
public string First_Name { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class TfaModel
|
||||
{
|
||||
public string TFAExpiration { get; set; } = "v";
|
||||
}
|
||||
|
||||
private sealed class CustomValue
|
||||
{
|
||||
public override string ToString() => "custom";
|
||||
}
|
||||
|
||||
private sealed class EchoNameModel
|
||||
{
|
||||
public string Name { get; set; } = "[NAME]";
|
||||
}
|
||||
|
||||
private sealed class DiscoveryModel
|
||||
{
|
||||
public static string Static { get; set; } = "static";
|
||||
|
||||
public string ReadOnly => "computed";
|
||||
|
||||
public string WriteOnly
|
||||
{
|
||||
set { }
|
||||
}
|
||||
|
||||
public string this[int index] => "indexer";
|
||||
|
||||
public string Shown { get; set; } = "shown";
|
||||
|
||||
private string Hidden { get; set; } = "hidden";
|
||||
}
|
||||
|
||||
private class BaseModel
|
||||
{
|
||||
public string Id { get; set; } = "base";
|
||||
}
|
||||
|
||||
private sealed class DerivedModel : BaseModel
|
||||
{
|
||||
public string Name { get; set; } = "derived";
|
||||
}
|
||||
|
||||
private sealed record PersonRecord(string FirstName, int Age);
|
||||
|
||||
private struct StructModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Box<T>
|
||||
{
|
||||
public T Value { get; set; } = default!;
|
||||
}
|
||||
|
||||
private sealed class TypeA
|
||||
{
|
||||
public string Name { get; set; } = "a";
|
||||
}
|
||||
|
||||
private sealed class TypeB
|
||||
{
|
||||
public string Name { get; set; } = "b";
|
||||
}
|
||||
|
||||
private sealed class ConcurrentModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user