Compare commits
40 Commits
4d069cdaa0
...
bugfix/ser
| Author | SHA1 | Date | |
|---|---|---|---|
| c2d8b6531c | |||
| 665266457f | |||
| 14d581acac | |||
| eb533a1dc2 | |||
| 36025fad7b | |||
| 0606efabf9 | |||
| 2cb1e266f7 | |||
| 35c1374b9c | |||
| feaea1626b | |||
| babd96200a | |||
| f9cfcf1f19 | |||
| ffb9e07466 | |||
| 5384a08905 | |||
| dda5ff28e1 | |||
| 043349a47f | |||
| c36f7ccf98 | |||
| cf3e85bcd7 | |||
| 77379265c2 | |||
| 9b68c09b0b | |||
| cd6bf05352 | |||
| b2c205b160 | |||
| 33439f4a65 | |||
| b5cdf79799 | |||
| 72915ac001 | |||
| e4d7a0d8f3 | |||
| d5f9de06f3 | |||
| bbc129178f | |||
| 40c95100ff | |||
| 1b2731b4b2 | |||
| ae56902758 | |||
| e80059d34e | |||
| a2443032c5 | |||
| 3d2fe532e5 | |||
| 5e83aa26c9 | |||
| 2c789cd4c0 | |||
| 9ecfe08e2e | |||
| 74da6e37b0 | |||
| 10f65e583a | |||
| 0cdc8f1191 | |||
| 56ccab6377 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -365,3 +365,8 @@ FodyWeavers.xsd
|
|||||||
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
|
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
|
||||||
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
|
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
|
||||||
/EnvelopeGenerator.Tests.Application/annotations.json
|
/EnvelopeGenerator.Tests.Application/annotations.json
|
||||||
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md
|
||||||
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||||
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||||
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||||
|
/ignore/EnvelopeGenerator.Form
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
if (reader.Read())
|
if (reader.Read())
|
||||||
{
|
{
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
if (!outSuccess)
|
||||||
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
@@ -221,8 +225,6 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
#region Create history
|
#region Create history
|
||||||
// ENV_UID, STATUS_ID, USER_ID,
|
// ENV_UID, STATUS_ID, USER_ID,
|
||||||
string sql_hist = @"
|
string sql_hist = @"
|
||||||
USE [DD_ECM]
|
|
||||||
|
|
||||||
DECLARE @OUT_SUCCESS bit;
|
DECLARE @OUT_SUCCESS bit;
|
||||||
|
|
||||||
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
||||||
@@ -244,6 +246,10 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
if (reader.Read())
|
if (reader.Read())
|
||||||
{
|
{
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
if (!outSuccess)
|
||||||
|
_logger.LogWarning(
|
||||||
|
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
|
||||||
|
envelope.Uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
|
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
|
||||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
|
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
|
||||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||||
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
||||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class EnvelopeReceiverAddReadSQL : ISQL<Envelope>
|
|||||||
/// ENV_UID, EMAIL_ADRESS, SALUTATION, PHONE,
|
/// ENV_UID, EMAIL_ADRESS, SALUTATION, PHONE,
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Raw => @"
|
public string Raw => @"
|
||||||
DECLARE @OUT_RECEIVER_ID int
|
DECLARE @OUT_RECEIVER_ID bigint
|
||||||
|
|
||||||
EXEC [dbo].[PRSIG_API_CREATE_RECEIVER]
|
EXEC [dbo].[PRSIG_API_CREATE_RECEIVER]
|
||||||
{0},
|
{0},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Imports System.IO
|
Imports System.IO
|
||||||
Imports DigitalData.Modules.Database
|
Imports DigitalData.Modules.Database
|
||||||
Imports DigitalData.Modules.Logging
|
Imports DigitalData.Modules.Logging
|
||||||
|
Imports EnvelopeGenerator.CommonServices
|
||||||
Imports EnvelopeGenerator.CommonServices.Jobs
|
Imports EnvelopeGenerator.CommonServices.Jobs
|
||||||
Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument
|
Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument
|
||||||
Imports GdPicture14
|
Imports GdPicture14
|
||||||
@@ -145,25 +146,23 @@ Public Class frmFinalizePDF
|
|||||||
log.AppendLine($"Document bytes: {oBuffer.Length}")
|
log.AppendLine($"Document bytes: {oBuffer.Length}")
|
||||||
|
|
||||||
' 3. Check what BurnAnnotsToPDF will do internally
|
' 3. Check what BurnAnnotsToPDF will do internally
|
||||||
|
Dim oEnvelopeEntity As Envelope = Nothing
|
||||||
Using scope = Factory.Shared.ScopeFactory.CreateScope()
|
Using scope = Factory.Shared.ScopeFactory.CreateScope()
|
||||||
Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)()
|
Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)()
|
||||||
Dim envelope = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
|
oEnvelopeEntity = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
|
||||||
|
|
||||||
If envelope Is Nothing Then
|
If oEnvelopeEntity Is Nothing Then
|
||||||
log.AppendLine("ERROR: Envelope not found in EF Core!")
|
log.AppendLine("ERROR: Envelope not found in EF Core!")
|
||||||
txtResult.Text = log.ToString()
|
txtResult.Text = log.ToString()
|
||||||
Return
|
Return
|
||||||
End If
|
End If
|
||||||
|
|
||||||
log.AppendLine($"Envelope found: Id={envelope.Id}, EnvelopeTypeId={envelope.EnvelopeTypeId}")
|
log.AppendLine($"Envelope found: Id={oEnvelopeEntity.Id}, EnvelopeTypeId={oEnvelopeEntity.EnvelopeTypeId}")
|
||||||
log.AppendLine($"ReadOnly (IsReadAndConfirm): {envelope.ReadOnly}")
|
log.AppendLine($"ReadOnly (IsReadAndConfirm): {oEnvelopeEntity.ReadOnly}")
|
||||||
|
|
||||||
If envelope.ReadOnly Then
|
|
||||||
log.AppendLine(">>> EARLY RETURN: ReadOnly=True, original PDF returned without burning")
|
|
||||||
txtResult.Text = log.ToString()
|
|
||||||
Return
|
|
||||||
End If
|
|
||||||
|
|
||||||
|
If oEnvelopeEntity.ReadOnly Then
|
||||||
|
log.AppendLine(">>> ReadOnly=True: original PDF will be used without burning (annotations skipped)")
|
||||||
|
Else
|
||||||
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
|
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
|
||||||
Dim elements = sigRepo _
|
Dim elements = sigRepo _
|
||||||
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
|
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
|
||||||
@@ -186,23 +185,52 @@ Public Class frmFinalizePDF
|
|||||||
Else
|
Else
|
||||||
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
|
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
|
||||||
End If
|
End If
|
||||||
|
End If
|
||||||
End Using
|
End Using
|
||||||
|
|
||||||
' 4. Actually call BurnAnnotsToPDF (same as Service)
|
' 4. Burn annotations (ReadOnly envelopes return original PDF unchanged)
|
||||||
log.AppendLine("")
|
log.AppendLine("")
|
||||||
log.AppendLine("Calling BurnAnnotsToPDF...")
|
log.AppendLine("Calling BurnAnnotsToPDF...")
|
||||||
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
|
Dim oBurnedBuffer As Byte() = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
|
||||||
log.AppendLine($"Result bytes: {oNewBuffer.Length}")
|
log.AppendLine($"Burned bytes: {oBurnedBuffer.Length}")
|
||||||
log.AppendLine($"Same as input: {oBuffer.Length = oNewBuffer.Length AndAlso oBuffer.SequenceEqual(oNewBuffer)}")
|
log.AppendLine($"Unchanged (ReadOnly): {oBuffer.Length = oBurnedBuffer.Length AndAlso oBuffer.SequenceEqual(oBurnedBuffer)}")
|
||||||
|
|
||||||
' 5. Write output
|
' 5. Build State and load Envelope domain model for ReportCreator
|
||||||
|
log.AppendLine("")
|
||||||
|
log.AppendLine("Loading envelope domain model for report...")
|
||||||
|
Dim oState As New State() With {
|
||||||
|
.Database = Database,
|
||||||
|
.LogConfig = LogConfig
|
||||||
|
}
|
||||||
|
Dim oEnvelopeModel As New EnvelopeModel(oState)
|
||||||
|
Dim oEnvelope = oEnvelopeModel.GetById(envelopeId)
|
||||||
|
If oEnvelope Is Nothing Then
|
||||||
|
log.AppendLine("ERROR: Envelope not found via EnvelopeModel!")
|
||||||
|
txtResult.Text = log.ToString()
|
||||||
|
Return
|
||||||
|
End If
|
||||||
|
log.AppendLine($"Envelope loaded: {oEnvelope.Id}")
|
||||||
|
|
||||||
|
' 6. Create signing certificate report
|
||||||
|
log.AppendLine("Creating signing certificate report...")
|
||||||
|
Dim oReportCreator As New ReportCreator(LogConfig, oState)
|
||||||
|
Dim oReportBuffer As Byte() = oReportCreator.CreateReport(oEnvelope)
|
||||||
|
log.AppendLine($"Report bytes: {oReportBuffer.Length}")
|
||||||
|
|
||||||
|
' 7. Merge burned document + report (last page)
|
||||||
|
log.AppendLine("Merging documents...")
|
||||||
|
Dim oMerger As New PDFMerger(LogConfig, pGDPictureLicenseKey)
|
||||||
|
Dim oMergedBuffer As Byte() = oMerger.MergeDocuments(oBurnedBuffer, oReportBuffer)
|
||||||
|
log.AppendLine($"Merged bytes: {oMergedBuffer.Length}")
|
||||||
|
|
||||||
|
' 8. Save and open
|
||||||
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
|
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
|
||||||
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.burned.pdf")
|
Dim oMergedPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.merged.pdf")
|
||||||
File.WriteAllBytes(oNewPath, oNewBuffer)
|
File.WriteAllBytes(oMergedPath, oMergedBuffer)
|
||||||
log.AppendLine($"Output: {oNewPath}")
|
log.AppendLine($"Output: {oMergedPath}")
|
||||||
|
|
||||||
txtResult.Text = log.ToString()
|
txtResult.Text = log.ToString()
|
||||||
Process.Start(oNewPath)
|
Process.Start(oMergedPath)
|
||||||
|
|
||||||
Catch ex As Exception
|
Catch ex As Exception
|
||||||
txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}"
|
txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ Namespace Jobs.FinalizeDocument
|
|||||||
Dim y = frameY + yOffsetofFF
|
Dim y = frameY + yOffsetofFF
|
||||||
|
|
||||||
If annot.Type = AnnotationType.FormField Then
|
If annot.Type = AnnotationType.FormField Then
|
||||||
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value)
|
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value, annot.Name)
|
||||||
ElseIf annot.Type = AnnotationType.Image Then
|
ElseIf annot.Type = AnnotationType.Image Then
|
||||||
AddImageAnnotation(
|
AddImageAnnotation(
|
||||||
annot.X / inchFactor,
|
annot.X / inchFactor,
|
||||||
@@ -269,11 +269,14 @@ Namespace Jobs.FinalizeDocument
|
|||||||
Next
|
Next
|
||||||
End Sub
|
End Sub
|
||||||
|
|
||||||
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String)
|
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String, Optional egName As String = "")
|
||||||
Manager.SelectPage(page)
|
Manager.SelectPage(page)
|
||||||
|
|
||||||
|
' Truncate if needed
|
||||||
|
Dim displayValue As String = TruncateText(value, egName)
|
||||||
|
|
||||||
' Add the text annotation
|
' Add the text annotation
|
||||||
Dim ant = Manager.AddTextAnnot(x, y, width, height, value)
|
Dim ant = Manager.AddTextAnnot(x, y, width, height, displayValue)
|
||||||
|
|
||||||
' Set the font properties
|
' Set the font properties
|
||||||
ant.FontName = _pdfBurnerParams.FontName
|
ant.FontName = _pdfBurnerParams.FontName
|
||||||
@@ -294,8 +297,9 @@ Namespace Jobs.FinalizeDocument
|
|||||||
Dim oHeight = oBounds.Item(3)
|
Dim oHeight = oBounds.Item(3)
|
||||||
|
|
||||||
Manager.SelectPage(pAnnotation.pageIndex + 1)
|
Manager.SelectPage(pAnnotation.pageIndex + 1)
|
||||||
' Add the text annotation
|
' Add the text annotation (truncate if needed)
|
||||||
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, formFieldValue.value)
|
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
|
||||||
|
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
|
||||||
|
|
||||||
' Set the font properties
|
' Set the font properties
|
||||||
ant.FontName = _pdfBurnerParams.FontName
|
ant.FontName = _pdfBurnerParams.FontName
|
||||||
@@ -306,6 +310,48 @@ Namespace Jobs.FinalizeDocument
|
|||||||
#End Region
|
#End Region
|
||||||
|
|
||||||
#Region "Helpers"
|
#Region "Helpers"
|
||||||
|
''' <summary>
|
||||||
|
''' Truncates a field value so its rendered width fits within the configured max width (inches).
|
||||||
|
''' Uses Graphics.MeasureString with the configured font for pixel-accurate measurement.
|
||||||
|
''' Appends TruncationSuffix when the value is cut.
|
||||||
|
''' </summary>
|
||||||
|
Private Function TruncateText(value As String, fieldEgName As String) As String
|
||||||
|
If String.IsNullOrEmpty(value) Then Return value
|
||||||
|
If _pdfBurnerParams.TextMaxWidths Is Nothing Then Return value
|
||||||
|
|
||||||
|
Dim maxWidthInches As Single = 0
|
||||||
|
If Not _pdfBurnerParams.TextMaxWidths.TryGetValue(fieldEgName, maxWidthInches) Then Return value
|
||||||
|
If maxWidthInches <= 0 Then Return value
|
||||||
|
|
||||||
|
Dim suffix As String = If(_pdfBurnerParams.TruncationSuffix, "")
|
||||||
|
|
||||||
|
Using bmp As New Bitmap(1, 1)
|
||||||
|
Using g As Graphics = Graphics.FromImage(bmp)
|
||||||
|
Dim font As New Font(_pdfBurnerParams.FontName, _pdfBurnerParams.FontSize, _pdfBurnerParams.FontStyle, GraphicsUnit.Point)
|
||||||
|
Dim maxWidthPx As Single = maxWidthInches * g.DpiX
|
||||||
|
|
||||||
|
' Already fits — no truncation needed
|
||||||
|
If g.MeasureString(value, font).Width <= maxWidthPx Then Return value
|
||||||
|
|
||||||
|
' Binary search for the longest prefix that fits (with suffix)
|
||||||
|
Dim lo As Integer = 0
|
||||||
|
Dim hi As Integer = value.Length
|
||||||
|
While lo < hi
|
||||||
|
Dim mid As Integer = (lo + hi + 1) \ 2
|
||||||
|
Dim candidate As String = value.Substring(0, mid) & suffix
|
||||||
|
If g.MeasureString(candidate, font).Width <= maxWidthPx Then
|
||||||
|
lo = mid
|
||||||
|
Else
|
||||||
|
hi = mid - 1
|
||||||
|
End If
|
||||||
|
End While
|
||||||
|
|
||||||
|
Return value.Substring(0, lo) & suffix
|
||||||
|
End Using
|
||||||
|
End Using
|
||||||
|
End Function
|
||||||
|
|
||||||
|
|
||||||
Private Function ToPointF(pPoints As List(Of Single)) As PointF
|
Private Function ToPointF(pPoints As List(Of Single)) As PointF
|
||||||
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
|
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
|
||||||
Return New PointF(oPoints.Item(0), oPoints.Item(1))
|
Return New PointF(oPoints.Item(0), oPoints.Item(1))
|
||||||
|
|||||||
@@ -12,5 +12,24 @@ Namespace Jobs.FinalizeDocument
|
|||||||
Public Property FontSize As Integer = 8
|
Public Property FontSize As Integer = 8
|
||||||
|
|
||||||
Public Property FontStyle As FontStyle = FontStyle.Italic
|
Public Property FontStyle As FontStyle = FontStyle.Italic
|
||||||
|
|
||||||
|
''' <summary>
|
||||||
|
''' The suffix appended to a truncated field value. Defaults to "..".
|
||||||
|
''' Can be changed to any string (e.g. "…", "...", "").
|
||||||
|
''' </summary>
|
||||||
|
Public Property TruncationSuffix As String = ".."
|
||||||
|
|
||||||
|
''' <summary>
|
||||||
|
''' Per-field maximum render width in inches.
|
||||||
|
''' Text is measured using the configured Font/FontSize via Graphics.MeasureString (pixel-accurate).
|
||||||
|
''' If the rendered width exceeds the limit, the text is cut and TruncationSuffix is appended.
|
||||||
|
''' Keys must match egName values used in PDFBurner (e.g. "name", "position", "city", "date").
|
||||||
|
''' Set a value to 0 to disable truncation for that field.
|
||||||
|
''' </summary>
|
||||||
|
Public Property TextMaxWidths As New Dictionary(Of String, Single) From {
|
||||||
|
{"position", 1.41F},
|
||||||
|
{"city", 1.41F},
|
||||||
|
{"date", 2.0F}
|
||||||
|
}
|
||||||
End Class
|
End Class
|
||||||
End Namespace
|
End Namespace
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
Imports EnvelopeGenerator.Domain.Constants
|
Imports EnvelopeGenerator.Domain.Constants
|
||||||
Imports EnvelopeGenerator.Domain.Entities
|
Imports EnvelopeGenerator.Domain.Entities
|
||||||
|
Imports EnvelopeGenerator.Domain.Interfaces
|
||||||
|
|
||||||
Public Class ReportItem
|
Public Class ReportItem
|
||||||
|
|
||||||
@@ -11,8 +12,15 @@ Public Class ReportItem
|
|||||||
Public Property ItemStatus As EnvelopeStatus
|
Public Property ItemStatus As EnvelopeStatus
|
||||||
Public ReadOnly Property ItemStatusTranslated As String
|
Public ReadOnly Property ItemStatusTranslated As String
|
||||||
Get
|
Get
|
||||||
Dim oStatus = ItemStatus.ToString()
|
Dim oStatusKey = ItemStatus.ToString()
|
||||||
Return My.Resources.Model.ResourceManager.GetString(oStatus)
|
|
||||||
|
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
|
||||||
|
Envelope IsNot Nothing AndAlso
|
||||||
|
Envelope.IsReadAndConfirm() Then
|
||||||
|
oStatusKey = "DocumentSignedRaC"
|
||||||
|
End If
|
||||||
|
|
||||||
|
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
|
||||||
End Get
|
End Get
|
||||||
End Property
|
End Property
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,9 @@
|
|||||||
<data name="DocumentSigned" xml:space="preserve">
|
<data name="DocumentSigned" xml:space="preserve">
|
||||||
<value>Document signed</value>
|
<value>Document signed</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DocumentSignedRaC" xml:space="preserve">
|
||||||
|
<value>Read and confirmed</value>
|
||||||
|
</data>
|
||||||
<data name="Draft" xml:space="preserve">
|
<data name="Draft" xml:space="preserve">
|
||||||
<value>Draft</value>
|
<value>Draft</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -168,6 +168,9 @@
|
|||||||
<data name="DocumentSigned" xml:space="preserve">
|
<data name="DocumentSigned" xml:space="preserve">
|
||||||
<value>Dokument unterzeichnet</value>
|
<value>Dokument unterzeichnet</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DocumentSignedRaC" xml:space="preserve">
|
||||||
|
<value>Gelesen und bestätigt</value>
|
||||||
|
</data>
|
||||||
<data name="Draft" xml:space="preserve">
|
<data name="Draft" xml:space="preserve">
|
||||||
<value>Entwurf</value>
|
<value>Entwurf</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ namespace My.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Gelesen und bestätigt.
|
||||||
|
/// </summary>
|
||||||
|
public static string DocumentSignedRaC {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Entwurf.
|
/// Looks up a localized string similar to Entwurf.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -168,6 +168,9 @@
|
|||||||
<data name="DocumentSigned" xml:space="preserve">
|
<data name="DocumentSigned" xml:space="preserve">
|
||||||
<value>Dokument unterzeichnet</value>
|
<value>Dokument unterzeichnet</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DocumentSignedRaC" xml:space="preserve">
|
||||||
|
<value>Gelesen und bestätigt</value>
|
||||||
|
</data>
|
||||||
<data name="Draft" xml:space="preserve">
|
<data name="Draft" xml:space="preserve">
|
||||||
<value>Entwurf</value>
|
<value>Entwurf</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
<ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid>
|
<ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid>
|
||||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
|
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
|
||||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||||
<DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath>
|
<DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath>
|
||||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||||
|
|||||||
@@ -138,7 +138,9 @@
|
|||||||
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||||
CustomizeElement="OnCustomizeElement">
|
CustomizeElement="OnCustomizeElement">
|
||||||
<Columns>
|
<Columns>
|
||||||
<DxGridDataColumn FieldName="Id" Caption="ID">
|
<DxGridDataColumn FieldName="Id" Caption="ID"
|
||||||
|
SortIndex="0"
|
||||||
|
SortOrder="GridColumnSortOrder.Descending">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
@@ -234,7 +236,9 @@
|
|||||||
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||||
CustomizeElement="OnCustomizeElement">
|
CustomizeElement="OnCustomizeElement">
|
||||||
<Columns>
|
<Columns>
|
||||||
<DxGridDataColumn FieldName="Id" Caption="ID">
|
<DxGridDataColumn FieldName="Id" Caption="ID"
|
||||||
|
SortIndex="0"
|
||||||
|
SortOrder="GridColumnSortOrder.Descending">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
@inject NavigationManager Navigation
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -26,7 +27,18 @@
|
|||||||
<span id="home-description"></span>
|
<span id="home-description"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-4 pt-3 border-top">
|
<div class="mt-4 pt-3 border-top d-flex flex-column align-items-center gap-4">
|
||||||
|
|
||||||
|
<button class="home-btn-primary btn px-4 py-2 d-flex align-items-center gap-2" @onclick='() => Navigation.NavigateTo("/sender")'>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||||
|
</svg>
|
||||||
|
Zur Umschlag-Übersicht
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap justify-content-center gap-3">
|
<div class="d-flex flex-wrap justify-content-center gap-3">
|
||||||
<div class="home-feature-badge">
|
<div class="home-feature-badge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
@@ -48,6 +60,7 @@
|
|||||||
PDF-Export
|
PDF-Export
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
if (result == EnvelopeLoginResult.Success)
|
if (result == EnvelopeLoginResult.Success)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report", forceLoad: true);
|
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||||
using EnvelopeGenerator.Server.Client.Models;
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Client.Services;
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
@@ -9,6 +10,7 @@ namespace EnvelopeGenerator.Server.Client.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
|
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
|
||||||
/// from <c>GET /api/EnvelopeReceiver/{envelopeKey}</c>.
|
/// from <c>GET /api/EnvelopeReceiver/{envelopeKey}</c>.
|
||||||
|
/// Also creates new envelopes via <c>POST /api/EnvelopeReceiver</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
@@ -37,4 +39,28 @@ public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
|||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
|
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new envelope with document and receivers via <c>POST /api/EnvelopeReceiver</c>.
|
||||||
|
/// Requires sender authentication cookie to be present in the request.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||||
|
public async Task<CreateEnvelopeReceiverResponse?> CreateAsync(
|
||||||
|
CreateEnvelopeReceiverCommand request,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.PostAsJsonAsync("/api/EnvelopeReceiver", request, _jsonOptions, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Fehler beim Erstellen des Umschlags. Status: {(int)response.StatusCode} – {body}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<CreateEnvelopeReceiverResponse>(_jsonOptions, cancel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,21 @@
|
|||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using DevExpress.Blazor.Reporting.Models
|
@using DevExpress.Blazor.Reporting.Models
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
|
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
|
@inject EnvelopeReceiverService EnvelopeReceiverService
|
||||||
|
@inject IMemoryCache MemoryCache
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<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" />
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||||||
|
|
||||||
@@ -22,14 +28,55 @@
|
|||||||
<div class="envelope-action-bar__inner"
|
<div class="envelope-action-bar__inner"
|
||||||
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
|
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
|
||||||
|
|
||||||
@* Left: Title *@
|
@* Left: Title + meta inputs + receivers *@
|
||||||
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;">
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; min-width: 0; flex-wrap: wrap;">
|
|
||||||
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;">
|
@* ── Title & Message inputs ── *@
|
||||||
Neues Dokument
|
<div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
|
||||||
|
@* Titel *@
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
|
||||||
|
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
Titel <span style="color:#dc2626;">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
value="@_envelopeTitle"
|
||||||
|
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
|
||||||
|
placeholder="z.B. Arbeitsvertrag 2025"
|
||||||
|
maxlength="120"
|
||||||
|
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
|
||||||
|
border: 1px solid #d1d5db; border-radius: 5px;
|
||||||
|
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
|
||||||
|
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
|
||||||
|
{
|
||||||
|
<span style="font-size:0.6rem;color:#dc2626;">Pflichtfeld</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Nachricht *@
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
|
||||||
|
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
Nachricht <span style="color:#6b7280;font-weight:400;">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
value="@_envelopeMessage"
|
||||||
|
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
|
||||||
|
placeholder="Bitte unterzeichnen Sie das beigefügte Dokument."
|
||||||
|
maxlength="500"
|
||||||
|
style="font-size: 0.78rem; color: #1f2937;
|
||||||
|
border: 1px solid #d1d5db; border-radius: 5px;
|
||||||
|
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||||||
|
background: #fff;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* PDF filename badge *@
|
||||||
@if (_pdfLoaded)
|
@if (_pdfLoaded)
|
||||||
{
|
{
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
|
||||||
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
|
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
|
||||||
@if (_signatureFields.Count > 0)
|
@if (_signatureFields.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -39,6 +86,7 @@
|
|||||||
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
|
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +118,8 @@
|
|||||||
<div class="sender-receivers-list">
|
<div class="sender-receivers-list">
|
||||||
@foreach (var receiver in _receivers)
|
@foreach (var receiver in _receivers)
|
||||||
{
|
{
|
||||||
<div class="sender-receiver-chip">
|
<div class="sender-receiver-chip"
|
||||||
|
style="border-left: 3px solid @receiver.Color;">
|
||||||
<div class="sender-receiver-chip__body">
|
<div class="sender-receiver-chip__body">
|
||||||
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
||||||
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
||||||
@@ -80,9 +129,11 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
|
||||||
@onclick="() => AddSignatureForReceiver(receiver)"
|
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
||||||
title="Signatur hinzufügen">
|
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||||
|
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
|
||||||
|
title="Signaturfeld platzieren">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -98,6 +149,12 @@
|
|||||||
@* Right: Buttons *@
|
@* Right: Buttons *@
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
|
@onclick="Cancel"
|
||||||
|
title="Abbrechen">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
|
||||||
@* PDF Upload *@
|
@* PDF Upload *@
|
||||||
<label class="pdf-toolbar__btn"
|
<label class="pdf-toolbar__btn"
|
||||||
title="PDF hochladen"
|
title="PDF hochladen"
|
||||||
@@ -114,23 +171,11 @@
|
|||||||
|
|
||||||
@if (_pdfLoaded)
|
@if (_pdfLoaded)
|
||||||
{
|
{
|
||||||
@* Toggle placement mode *@
|
|
||||||
<button class="pdf-toolbar__btn @(_placementMode ? "pdf-toolbar__btn--signature-change-active" : "pdf-toolbar__btn--signature-change")"
|
|
||||||
@onclick="TogglePlacementMode"
|
|
||||||
title="@(_placementMode ? "Platzierungsmodus beenden" : "Signaturfeld hinzufügen")">
|
|
||||||
<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">
|
|
||||||
@(_placementMode ? "Abbrechen" : "+ Signaturfeld")
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@* Clear all fields *@
|
@* Clear all fields *@
|
||||||
@if (_signatureFields.Count > 0)
|
@if (_signatureFields.Count > 0)
|
||||||
{
|
{
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
@onclick="ClearAllFields"
|
@onclick="ClearAllFieldsAsync"
|
||||||
title="Alle Signaturfelder entfernen">
|
title="Alle Signaturfelder entfernen">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
@@ -142,11 +187,20 @@
|
|||||||
@* Save *@
|
@* Save *@
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||||
@onclick="SaveAsync"
|
@onclick="SaveAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
title="Speichern"
|
title="Speichern"
|
||||||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
}
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -154,11 +208,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Placement mode hint bar *@
|
@* Placement mode hint bar *@
|
||||||
@if (_placementMode)
|
@if (_pendingReceiverForPlacement is not null)
|
||||||
{
|
{
|
||||||
<div style="background: #4F46E5; color: white; font-size: 0.75rem; font-weight: 500;
|
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
|
||||||
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
||||||
📌 Klicken Sie auf die gewünschte Stelle im Dokument, um ein Signaturfeld zu platzieren.
|
📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
|
||||||
|
<button @onclick="CancelPlacement"
|
||||||
|
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,63 +260,13 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* PDF viewer + overlay wrapper *@
|
@* PDF viewer — click capture active only in placement mode *@
|
||||||
<div id="pdf-editor-wrapper" class="pdf-editor-wrapper"
|
<div class="pdf-editor-wrapper"
|
||||||
style="position: relative; width: 100%; height: 100%; overflow: auto;">
|
style="cursor: @(_pendingReceiverForPlacement is not null ? "crosshair" : "default");"
|
||||||
|
@onclick="OnPdfAreaClickAsync">
|
||||||
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
|
|
||||||
<DxPdfViewer @ref="_pdfViewer"
|
<DxPdfViewer @ref="_pdfViewer"
|
||||||
DocumentContent="@_pdfBytes"
|
DocumentContent="@_pdfBytes"
|
||||||
ZoomLevel="1.0"
|
|
||||||
IsSinglePagePreview="false"
|
|
||||||
CssClass="sender-editor-pdf-viewer" />
|
CssClass="sender-editor-pdf-viewer" />
|
||||||
|
|
||||||
@* Transparent overlay for click capture (active only in placement mode) *@
|
|
||||||
<div id="pdf-editor-overlay"
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: @(_placementMode ? "auto" : "none");
|
|
||||||
cursor: @(_placementMode ? "crosshair" : "default");"
|
|
||||||
@onclick="OnOverlayClickAsync">
|
|
||||||
|
|
||||||
@* Render placed signature field placeholders *@
|
|
||||||
@foreach (var field in _signatureFields)
|
|
||||||
{
|
|
||||||
var f = field; // capture for lambda
|
|
||||||
<div style="position: absolute;
|
|
||||||
left: @(f.DisplayX.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
|
||||||
top: @(f.DisplayY.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
|
||||||
width: @(SigDisplayW)px;
|
|
||||||
height: @(SigDisplayH)px;
|
|
||||||
border: 2px dashed #4F46E5;
|
|
||||||
background: rgba(79,70,229,0.10);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: default;"
|
|
||||||
@onclick:stopPropagation="true">
|
|
||||||
<span style="font-size: 0.6rem; font-weight: 700; color: #4F46E5;
|
|
||||||
letter-spacing: 0.05em; text-transform: uppercase;">
|
|
||||||
Unterschrift
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.55rem; color: #6d28d9; opacity: 0.8;">
|
|
||||||
S.@f.Page
|
|
||||||
</span>
|
|
||||||
<button @onclick="() => RemoveField(f)"
|
|
||||||
style="position: absolute; top: 2px; right: 4px;
|
|
||||||
background: none; border: none; cursor: pointer;
|
|
||||||
color: #6d28d9; font-size: 0.75rem; line-height: 1;
|
|
||||||
padding: 0; opacity: 0.7;"
|
|
||||||
title="Entfernen">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +320,7 @@
|
|||||||
TValue="string"
|
TValue="string"
|
||||||
Data="@_receiverEmailSuggestions"
|
Data="@_receiverEmailSuggestions"
|
||||||
Value="@_selectedReceiverEmailSuggestion"
|
Value="@_selectedReceiverEmailSuggestion"
|
||||||
ValueChanged="OnReceiverEmailSuggestionSelectedAsync"
|
ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
|
||||||
SelectionMode="ListBoxSelectionMode.Single"
|
SelectionMode="ListBoxSelectionMode.Single"
|
||||||
CssClass="w-100" />
|
CssClass="w-100" />
|
||||||
</div>
|
</div>
|
||||||
@@ -348,25 +356,116 @@
|
|||||||
</FooterContentTemplate>
|
</FooterContentTemplate>
|
||||||
</DxPopup>
|
</DxPopup>
|
||||||
|
|
||||||
|
@* ── Save result popup (success + error) ── *@
|
||||||
|
<DxPopup @bind-Visible="_savePopupVisible"
|
||||||
|
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
|
||||||
|
Width="460px"
|
||||||
|
MaxWidth="95vw"
|
||||||
|
ShowFooter="true"
|
||||||
|
CloseOnOutsideClick="false"
|
||||||
|
ShowCloseButton="false"
|
||||||
|
CloseOnEscape="false">
|
||||||
|
<BodyContentTemplate>
|
||||||
|
@if (_saveErrorMessage is null)
|
||||||
|
{
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||||||
|
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
|
||||||
|
display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
|
Umschlag wurde erfolgreich erstellt.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||||||
|
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||||||
|
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
|
||||||
|
display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
|
Fehler beim Erstellen des Umschlags
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
|
||||||
|
@_saveErrorMessage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</BodyContentTemplate>
|
||||||
|
<FooterContentTemplate>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||||
|
@if (_saveErrorMessage is null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@onclick="GoToDashboard"
|
||||||
|
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||||||
|
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||||||
|
Zur Übersicht
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary"
|
||||||
|
@onclick="() => _savePopupVisible = false"
|
||||||
|
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</FooterContentTemplate>
|
||||||
|
</DxPopup>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
// ── Session query param — persists across SignalR reconnects ──
|
||||||
|
[SupplyParameterFromQuery(Name = "esid")]
|
||||||
|
public string? Esid { get; set; }
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch
|
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||||
|
|
||||||
// Display size of the overlay placeholder (pixels at zoom=1.0).
|
// CssClass for DxPdfViewer — used by JS to locate page elements
|
||||||
// At zoom=1.0, 1 CSS px ≈ 1 pt in the DxPdfViewer render.
|
const string ViewerCssClass = "sender-editor-pdf-viewer";
|
||||||
const double SigDisplayW = SigWidthPt;
|
|
||||||
const double SigDisplayH = SigHeightPt;
|
// Cache TTL for editor session (30 min of inactivity)
|
||||||
|
static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
DxPdfViewer? _pdfViewer;
|
DxPdfViewer? _pdfViewer;
|
||||||
byte[]? _pdfBytes;
|
|
||||||
bool _pdfLoaded = false;
|
bool _pdfLoaded = false;
|
||||||
string _fileName = string.Empty;
|
string _fileName = string.Empty;
|
||||||
string? _errorMessage;
|
string? _errorMessage;
|
||||||
bool _placementMode = false;
|
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||||
|
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||||
|
|
||||||
List<SignatureFieldDraft> _signatureFields = [];
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
|
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||||
|
|
||||||
|
// ── Save state ──
|
||||||
|
bool _isSaving = false;
|
||||||
|
bool _savePopupVisible = false;
|
||||||
|
string? _saveErrorMessage = null;
|
||||||
|
|
||||||
|
// ── Envelope metadata ──
|
||||||
|
string _envelopeTitle = string.Empty;
|
||||||
|
string _envelopeMessage = string.Empty;
|
||||||
|
bool _titleTouched = false;
|
||||||
|
|
||||||
List<ReceiverDraft> _receivers = [];
|
List<ReceiverDraft> _receivers = [];
|
||||||
bool _receiverPopupVisible;
|
bool _receiverPopupVisible;
|
||||||
string _receiverDraftName = string.Empty;
|
string _receiverDraftName = string.Empty;
|
||||||
@@ -377,8 +476,55 @@
|
|||||||
bool _isReceiverEmailSearchRunning;
|
bool _isReceiverEmailSearchRunning;
|
||||||
List<string> _receiverEmailSuggestions = [];
|
List<string> _receiverEmailSuggestions = [];
|
||||||
int _receiverEmailSearchVersion;
|
int _receiverEmailSearchVersion;
|
||||||
|
|
||||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||||
|
|
||||||
|
// ── Cache key helper ──
|
||||||
|
string SessionKey => $"sender-editor:{Esid}";
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
// Intentionally empty.
|
||||||
|
// esid redirect is done in OnAfterRenderAsync to avoid NavigationException
|
||||||
|
// during SSR prerendering, where NavigateTo throws by design.
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
// Generate a session id on first interactive render and push it into the URL.
|
||||||
|
// At this point SignalR is connected and NavigateTo is safe.
|
||||||
|
if (string.IsNullOrWhiteSpace(Esid))
|
||||||
|
{
|
||||||
|
var sid = Guid.NewGuid().ToString("N");
|
||||||
|
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// After the esid is set via query param, try to restore from cache.
|
||||||
|
if (!string.IsNullOrWhiteSpace(Esid)
|
||||||
|
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
||||||
|
&& cached is not null)
|
||||||
|
{
|
||||||
|
_originalPdfBytes = cached.OriginalPdfBytes;
|
||||||
|
_signatureFields = cached.Fields;
|
||||||
|
_fileName = cached.FileName;
|
||||||
|
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||||
|
_receivers = cached.Receivers;
|
||||||
|
_envelopeTitle = cached.Title;
|
||||||
|
_envelopeMessage = cached.Message;
|
||||||
|
|
||||||
|
// Redraw placeholders onto the original PDF
|
||||||
|
if (_pdfLoaded)
|
||||||
|
_pdfBytes = DrawPlaceholders(_originalPdfBytes!, _signatureFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── PDF upload ──
|
// ── PDF upload ──
|
||||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -394,17 +540,22 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Max 50 MB
|
|
||||||
const long maxBytes = 50 * 1024 * 1024;
|
const long maxBytes = 50 * 1024 * 1024;
|
||||||
using var ms = new System.IO.MemoryStream();
|
using var ms = new System.IO.MemoryStream();
|
||||||
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
|
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
|
||||||
_pdfBytes = ms.ToArray();
|
|
||||||
|
_originalPdfBytes = ms.ToArray();
|
||||||
_fileName = file.Name;
|
_fileName = file.Name;
|
||||||
_pdfLoaded = true;
|
_pdfLoaded = true;
|
||||||
_signatureFields.Clear();
|
_signatureFields.Clear();
|
||||||
_placementMode = false;
|
_pendingReceiverForPlacement = null;
|
||||||
|
|
||||||
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _pdfBytes.Length);
|
// Rendered PDF starts as the clean original (no placeholders yet)
|
||||||
|
_pdfBytes = _originalPdfBytes;
|
||||||
|
|
||||||
|
PersistSession();
|
||||||
|
|
||||||
|
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -414,79 +565,300 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Placement mode ──
|
// ── Placement mode ──
|
||||||
void TogglePlacementMode() => _placementMode = !_placementMode;
|
void ActivatePlacementForReceiver(ReceiverDraft receiver)
|
||||||
|
|
||||||
void ClearAllFields()
|
|
||||||
{
|
{
|
||||||
_signatureFields.Clear();
|
// Toggle: clicking the same receiver again cancels placement
|
||||||
_placementMode = false;
|
_pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id
|
||||||
|
? null
|
||||||
|
: receiver;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field);
|
void CancelPlacement() => _pendingReceiverForPlacement = null;
|
||||||
|
|
||||||
// ── Overlay click → add signature field ──
|
// ── PDF area click → place field ──
|
||||||
async Task OnOverlayClickAsync(MouseEventArgs e)
|
async Task OnPdfAreaClickAsync(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_placementMode) return;
|
if (_pendingReceiverForPlacement is null) return;
|
||||||
|
if (!_pdfLoaded || _originalPdfBytes is null) return;
|
||||||
|
|
||||||
// Get overlay container bounds via JS
|
// Ask JS for the normalised click position within the rendered PDF page
|
||||||
var coords = await JSRuntime.InvokeAsync<OverlayCoords>(
|
var coords = await JSRuntime.InvokeAsync<NormalisedCoords?>(
|
||||||
"envelopeEditor.getClickCoords", "pdf-editor-overlay",
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
e.ClientX, e.ClientY);
|
ViewerCssClass, e.ClientX, e.ClientY);
|
||||||
|
|
||||||
if (coords is null) return;
|
if (coords is null)
|
||||||
|
|
||||||
// At zoom=1.0: container pixels ≈ PDF display pixels.
|
|
||||||
// DxPdfViewer renders at 96 dpi by default; PDF points = 72 dpi.
|
|
||||||
// Scale factor: 96/72 = 1.333 → px / 1.333 = pt
|
|
||||||
const double pxToPt = 72.0 / 96.0;
|
|
||||||
|
|
||||||
double xPt = coords.RelX * pxToPt;
|
|
||||||
double yPt = coords.RelY * pxToPt;
|
|
||||||
|
|
||||||
// Active page: DxPdfViewer.ActivePageIndex is 0-based
|
|
||||||
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
|
|
||||||
|
|
||||||
// Display position (px on overlay) — keep in px for CSS
|
|
||||||
double displayX = coords.RelX;
|
|
||||||
double displayY = coords.RelY;
|
|
||||||
|
|
||||||
// Prevent placing outside bounds
|
|
||||||
if (displayX < 0 || displayY < 0) return;
|
|
||||||
if (displayX + SigDisplayW > coords.ContainerW) displayX = coords.ContainerW - SigDisplayW;
|
|
||||||
if (displayY + SigDisplayH > coords.ContainerH) displayY = coords.ContainerH - SigDisplayH;
|
|
||||||
|
|
||||||
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
|
|
||||||
_signatureFields.Add(field);
|
|
||||||
|
|
||||||
Logger.LogInformation(
|
|
||||||
"Signature field added: Page={Page} X={X:F1}pt Y={Y:F1}pt",
|
|
||||||
page, xPt, yPt);
|
|
||||||
|
|
||||||
// Exit placement mode after one click (user can re-click button for next)
|
|
||||||
_placementMode = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save ──
|
|
||||||
async Task SaveAsync()
|
|
||||||
{
|
{
|
||||||
if (_signatureFields.Count == 0)
|
Logger.LogWarning("[SenderEditor] getClickCoordsOnPdfPage returned null");
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync("console.log",
|
|
||||||
"[SenderEditor] No signature fields to save.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var f in _signatureFields)
|
// Read page dimensions from the original PDF via PdfSharp
|
||||||
|
double pageWidthPt;
|
||||||
|
double pageHeightPt;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("console.log",
|
using var ms = new System.IO.MemoryStream(_originalPdfBytes);
|
||||||
$"[SenderEditor] Field: Page={f.Page} X={f.XPt:F2}pt ({f.XPt/72:F3}in) Y={f.YPt:F2}pt ({f.YPt/72:F3}in)");
|
var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
|
||||||
|
int pageIndex = Math.Max(0, Math.Min(coords.PageIndex, doc.PageCount - 1));
|
||||||
|
var page = doc.Pages[pageIndex];
|
||||||
|
pageWidthPt = page.Width.Point;
|
||||||
|
pageHeightPt = page.Height.Point;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "[SenderEditor] Failed to read page dimensions from PDF");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await JSRuntime.InvokeVoidAsync("console.log",
|
// Convert normalised [0,1] → PDF points; clamp so box stays inside page
|
||||||
$"[SenderEditor] Total fields: {_signatureFields.Count}");
|
double xPt = coords.NormX * pageWidthPt;
|
||||||
|
double yPt = coords.NormY * pageHeightPt;
|
||||||
|
|
||||||
|
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
|
||||||
|
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
|
||||||
|
|
||||||
|
int page1Based = coords.PageIndex + 1;
|
||||||
|
|
||||||
|
var field = new SignatureFieldDraft(
|
||||||
|
XPt: xPt,
|
||||||
|
YPt: yPt,
|
||||||
|
Page: page1Based,
|
||||||
|
ReceiverName: _pendingReceiverForPlacement.FullName,
|
||||||
|
Color: _pendingReceiverForPlacement.Color);
|
||||||
|
|
||||||
|
_signatureFields.Add(field);
|
||||||
|
_pendingReceiverForPlacement = null;
|
||||||
|
|
||||||
|
// Burn all placeholders onto the original PDF and update the viewer
|
||||||
|
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields);
|
||||||
|
PersistSession();
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}",
|
||||||
|
page1Based, xPt, yPt, field.ReceiverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remove a single field ──
|
||||||
|
async Task RemoveFieldAsync(SignatureFieldDraft field)
|
||||||
|
{
|
||||||
|
_signatureFields.Remove(field);
|
||||||
|
_pdfBytes = _originalPdfBytes is not null
|
||||||
|
? DrawPlaceholders(_originalPdfBytes, _signatureFields)
|
||||||
|
: _pdfBytes;
|
||||||
|
PersistSession();
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clear all fields ──
|
||||||
|
async Task ClearAllFieldsAsync()
|
||||||
|
{
|
||||||
|
_signatureFields.Clear();
|
||||||
|
_pendingReceiverForPlacement = null;
|
||||||
|
_pdfBytes = _originalPdfBytes;
|
||||||
|
PersistSession();
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Cancel() => NavigationManager.NavigateTo("/sender");
|
||||||
|
|
||||||
|
void GoToDashboard()
|
||||||
|
{
|
||||||
|
// Clear session from cache on successful save so it is not restored if user returns
|
||||||
|
if (!string.IsNullOrWhiteSpace(Esid))
|
||||||
|
MemoryCache.Remove(SessionKey);
|
||||||
|
|
||||||
|
NavigationManager.NavigateTo("/sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save — POST /api/EnvelopeReceiver ──
|
||||||
|
async Task SaveAsync()
|
||||||
|
{
|
||||||
|
// ── Validation ──
|
||||||
|
_titleTouched = true;
|
||||||
|
if (!_pdfLoaded || _originalPdfBytes is null)
|
||||||
|
{
|
||||||
|
_saveErrorMessage = "Bitte laden Sie zuerst ein PDF-Dokument hoch.";
|
||||||
|
_savePopupVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(_envelopeTitle))
|
||||||
|
{
|
||||||
|
_saveErrorMessage = "Bitte geben Sie einen Titel ein.";
|
||||||
|
_savePopupVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_receivers.Count == 0)
|
||||||
|
{
|
||||||
|
_saveErrorMessage = "Bitte fügen Sie mindestens einen Empfänger hinzu.";
|
||||||
|
_savePopupVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_signatureFields.Count == 0)
|
||||||
|
{
|
||||||
|
_saveErrorMessage = "Bitte platzieren Sie mindestens ein Signaturfeld.";
|
||||||
|
_savePopupVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if any receiver has no signature field, but don't block
|
||||||
|
var receiversWithoutField = _receivers
|
||||||
|
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
|
||||||
|
.ToList();
|
||||||
|
if (receiversWithoutField.Count > 0)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(
|
||||||
|
"[SenderEditor] Receivers without signature field: {Names}",
|
||||||
|
string.Join(", ", receiversWithoutField.Select(r => r.FullName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSaving = true;
|
||||||
|
_saveErrorMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ── Build request ──
|
||||||
|
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
|
||||||
|
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
|
||||||
|
|
||||||
|
// Build receivers list — each receiver gets their own signature fields
|
||||||
|
// Coordinate conversion: PDF points → inches (DB stores inches)
|
||||||
|
var receiversCmd = _receivers.Select(receiver =>
|
||||||
|
{
|
||||||
|
var fields = _signatureFields
|
||||||
|
.Where(f => f.ReceiverName == receiver.FullName)
|
||||||
|
.Select(f => new DocReceiverElementCreateDto(
|
||||||
|
X: f.XPt / 72.0,
|
||||||
|
Y: f.YPt / 72.0,
|
||||||
|
Page: f.Page))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ReceiverGetOrCreateCommand
|
||||||
|
{
|
||||||
|
EmailAddress = receiver.Email,
|
||||||
|
Salution = receiver.FullName,
|
||||||
|
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
|
||||||
|
? null
|
||||||
|
: receiver.PhoneNumber,
|
||||||
|
DocReceiverElements = fields,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var command = new CreateEnvelopeReceiverCommand
|
||||||
|
{
|
||||||
|
Title = _envelopeTitle.Trim(),
|
||||||
|
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
|
||||||
|
? "Bitte unterzeichnen Sie das beigefügte Dokument."
|
||||||
|
: _envelopeMessage.Trim(),
|
||||||
|
TFAEnabled = false,
|
||||||
|
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
|
||||||
|
Receivers = receiversCmd,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await EnvelopeReceiverService.CreateAsync(command);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
|
||||||
|
result?.Id, result?.SentReceiver.Count());
|
||||||
|
|
||||||
|
// Success — show popup; GoToDashboard clears cache and navigates
|
||||||
|
_saveErrorMessage = null;
|
||||||
|
_savePopupVisible = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
|
||||||
|
_saveErrorMessage = ex.Message;
|
||||||
|
_savePopupVisible = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache persistence ──
|
||||||
|
void PersistSession()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Esid)) return;
|
||||||
|
|
||||||
|
var data = new EditorSessionData(
|
||||||
|
OriginalPdfBytes: _originalPdfBytes ?? [],
|
||||||
|
Fields: [.. _signatureFields],
|
||||||
|
FileName: _fileName,
|
||||||
|
Receivers: [.. _receivers],
|
||||||
|
Title: _envelopeTitle,
|
||||||
|
Message: _envelopeMessage);
|
||||||
|
|
||||||
|
MemoryCache.Set(SessionKey, data, SessionTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PdfSharp: burn all placeholder boxes onto the original PDF ──
|
||||||
|
static byte[] DrawPlaceholders(byte[] originalPdf, IReadOnlyList<SignatureFieldDraft> fields)
|
||||||
|
{
|
||||||
|
if (fields.Count == 0) return originalPdf;
|
||||||
|
|
||||||
|
using var inputMs = new System.IO.MemoryStream(originalPdf);
|
||||||
|
using var outputMs = new System.IO.MemoryStream();
|
||||||
|
|
||||||
|
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||||
|
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
int pageIndex = field.Page - 1;
|
||||||
|
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||||
|
|
||||||
|
var page = document.Pages[pageIndex];
|
||||||
|
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||||
|
|
||||||
|
// Derive colours from the receiver's hex colour
|
||||||
|
var fillBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 35));
|
||||||
|
var borderPen = new PdfSharp.Drawing.XPen(HexToXColor(field.Color, alpha: 210), 1.5);
|
||||||
|
var textBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 200));
|
||||||
|
var nameBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 230));
|
||||||
|
|
||||||
|
var fontLabel = new PdfSharp.Drawing.XFont("Arial", 9, PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||||
|
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
|
||||||
|
|
||||||
|
var fmtCenter = new PdfSharp.Drawing.XStringFormat
|
||||||
|
{
|
||||||
|
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
|
||||||
|
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
var rect = new PdfSharp.Drawing.XRect(field.XPt, field.YPt, SigWidthPt, SigHeightPt);
|
||||||
|
|
||||||
|
gfx.DrawRectangle(fillBrush, rect);
|
||||||
|
gfx.DrawRectangle(borderPen, rect);
|
||||||
|
|
||||||
|
// "UNTERSCHRIFT" label centred in upper two-thirds
|
||||||
|
var labelRect = new PdfSharp.Drawing.XRect(
|
||||||
|
field.XPt, field.YPt, SigWidthPt, SigHeightPt * 0.65);
|
||||||
|
gfx.DrawString("UNTERSCHRIFT", fontLabel, textBrush, labelRect, fmtCenter);
|
||||||
|
|
||||||
|
// Receiver name centred in lower third
|
||||||
|
var nameRect = new PdfSharp.Drawing.XRect(
|
||||||
|
field.XPt + 4, field.YPt + SigHeightPt * 0.68,
|
||||||
|
SigWidthPt - 8, SigHeightPt * 0.30);
|
||||||
|
var displayName = field.ReceiverName.Length > 22
|
||||||
|
? field.ReceiverName[..19] + "..."
|
||||||
|
: field.ReceiverName;
|
||||||
|
gfx.DrawString(displayName, fontName, nameBrush, nameRect, fmtCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Save(outputMs);
|
||||||
|
return outputMs.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts a CSS hex colour string (e.g. "#4F46E5") to a PdfSharp XColor.</summary>
|
||||||
|
static PdfSharp.Drawing.XColor HexToXColor(string hex, int alpha)
|
||||||
|
{
|
||||||
|
var c = System.Drawing.ColorTranslator.FromHtml(hex);
|
||||||
|
return PdfSharp.Drawing.XColor.FromArgb(alpha, c.R, c.G, c.B);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receiver popup ──
|
||||||
void OpenAddReceiverPopup()
|
void OpenAddReceiverPopup()
|
||||||
{
|
{
|
||||||
_receiverDraftName = string.Empty;
|
_receiverDraftName = string.Empty;
|
||||||
@@ -522,37 +894,32 @@
|
|||||||
{
|
{
|
||||||
if (_receiverEmailSuggestions.Count == 0)
|
if (_receiverEmailSuggestions.Count == 0)
|
||||||
{
|
{
|
||||||
if (e.Key == "Escape")
|
if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null;
|
||||||
_selectedReceiverEmailSuggestion = null;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentIndex = _selectedReceiverEmailSuggestion is null
|
var currentIndex = _selectedReceiverEmailSuggestion is null
|
||||||
? -1
|
? -1
|
||||||
: _receiverEmailSuggestions.FindIndex(email => string.Equals(email, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
|
: _receiverEmailSuggestions.FindIndex(em =>
|
||||||
|
string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (e.Key == "ArrowDown")
|
if (e.Key == "ArrowDown")
|
||||||
{
|
{
|
||||||
var nextIndex = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
|
var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
|
||||||
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]);
|
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
|
||||||
}
|
}
|
||||||
else if (e.Key == "ArrowUp")
|
else if (e.Key == "ArrowUp")
|
||||||
{
|
{
|
||||||
var nextIndex = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
|
var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
|
||||||
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]);
|
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
|
||||||
}
|
}
|
||||||
else if (e.Key == "Enter")
|
else if (e.Key == "Enter")
|
||||||
{
|
{
|
||||||
var selectedValue = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
|
var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
|
||||||
? _receiverEmailSuggestions[currentIndex]
|
? _receiverEmailSuggestions[currentIndex]
|
||||||
: _receiverEmailSuggestions.FirstOrDefault();
|
: _receiverEmailSuggestions.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(sel))
|
||||||
if (!string.IsNullOrWhiteSpace(selectedValue))
|
await OnReceiverEmailSuggestionCommittedAsync(sel);
|
||||||
{
|
|
||||||
await OnReceiverEmailSuggestionSelectedAsync(selectedValue);
|
|
||||||
_receiverEmailSuggestions.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (e.Key == "Escape")
|
else if (e.Key == "Escape")
|
||||||
{
|
{
|
||||||
@@ -561,14 +928,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Task OnReceiverEmailSuggestionSelectedAsync(string? value)
|
void SelectReceiverEmailSuggestion(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value)) return;
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
_selectedReceiverEmailSuggestion = value.Trim();
|
_selectedReceiverEmailSuggestion = value.Trim();
|
||||||
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
|
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
|
||||||
_receiverPopupValidationMessage = null;
|
_receiverPopupValidationMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
|
||||||
|
{
|
||||||
|
SelectReceiverEmailSuggestion(value);
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,19 +964,17 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
||||||
|
if (searchVersion != _receiverEmailSearchVersion) return;
|
||||||
if (searchVersion != _receiverEmailSearchVersion)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_receiverEmailSuggestions = results
|
_receiverEmailSuggestions = results
|
||||||
.Where(email => !string.IsNullOrWhiteSpace(email))
|
.Where(em => !string.IsNullOrWhiteSpace(em))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(email => email)
|
.OrderBy(em => em)
|
||||||
.Take(12)
|
.Take(12)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(email =>
|
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em =>
|
||||||
string.Equals(email, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
|
string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -634,50 +1003,54 @@
|
|||||||
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(email))
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ReceiverEmailValidator.IsValid(email))
|
if (!ReceiverEmailValidator.IsValid(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
|
||||||
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber));
|
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
|
||||||
|
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
|
||||||
|
PersistSession();
|
||||||
CloseAddReceiverPopup();
|
CloseAddReceiverPopup();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
void AddSignatureForReceiver(ReceiverDraft receiver)
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Signature placement requested for receiver {Email}", receiver.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!_pdfLoaded || _errorMessage is not null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await JSRuntime.InvokeVoidAsync(
|
|
||||||
"envelopeEditor.syncOverlayToPage",
|
|
||||||
"pdf-editor-wrapper",
|
|
||||||
"pdf-editor-overlay");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Models ──
|
// ── Models ──
|
||||||
record SignatureFieldDraft(double XPt, double YPt, int Page, double DisplayX, double DisplayY);
|
record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
|
||||||
|
|
||||||
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH);
|
record NormalisedCoords(double NormX, double NormY, int PageIndex);
|
||||||
|
|
||||||
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber);
|
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
|
||||||
|
|
||||||
|
record EditorSessionData(
|
||||||
|
byte[] OriginalPdfBytes,
|
||||||
|
List<SignatureFieldDraft> Fields,
|
||||||
|
string FileName,
|
||||||
|
List<ReceiverDraft> Receivers,
|
||||||
|
string Title,
|
||||||
|
string Message);
|
||||||
|
|
||||||
|
// ── Receiver colour palette (cycles when > 8 receivers) ──
|
||||||
|
static readonly string[] ReceiverPalette =
|
||||||
|
[
|
||||||
|
"#4F46E5", // indigo
|
||||||
|
"#059669", // emerald
|
||||||
|
"#DC2626", // red
|
||||||
|
"#D97706", // amber
|
||||||
|
"#7C3AED", // violet
|
||||||
|
"#0891B2", // cyan
|
||||||
|
"#BE185D", // pink
|
||||||
|
"#65A30D", // lime
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/envelope/{EnvelopeKey}/report"
|
@page "/envelope/{EnvelopeKey}"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@using DevExpress.Blazor.Reporting
|
@using DevExpress.Blazor.Reporting
|
||||||
@using DevExpress.XtraReports.UI
|
@using DevExpress.XtraReports.UI
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<EnvelopeReceiverReportPage> Logger
|
@inject ILogger<ReceiverPage> Logger
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<EnvelopeReceiverReportSignedPage> Logger
|
@inject ILogger<ReceiverSignedPage> Logger
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
|
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
|
||||||
Sid, EnvelopeKey);
|
Sid, EnvelopeKey);
|
||||||
Navigation.NavigateTo(
|
Navigation.NavigateTo(
|
||||||
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report",
|
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}",
|
||||||
forceLoad: true);
|
forceLoad: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/envelope/{EnvelopeKey}"
|
@page "/envelope/{EnvelopeKey}/deprc"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@using EnvelopeGenerator.Server.Client.Models
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
@using EnvelopeGenerator.Server.Client.Models.Constants
|
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||||
@@ -40,7 +40,7 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
|
|||||||
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
|
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
||||||
[Authorize(Policy = AuthPolicy.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
[HttpPost("logout")]
|
[HttpPost("logout")]
|
||||||
public IActionResult Logout()
|
public IActionResult Logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
|
|||||||
/// <param name="query">Encoded envelope key.</param>
|
/// <param name="query">Encoded envelope key.</param>
|
||||||
/// <param name="cancel">Cancellation token.</param>
|
/// <param name="cancel">Cancellation token.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = AuthPolicy.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
||||||
{
|
{
|
||||||
if (query is null)
|
if (query is null)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
/// </param>
|
/// </param>
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = AuthPolicy.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
public class EmailTemplateController(IMediator mediator) : ControllerBase
|
public class EmailTemplateController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
|
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
|
||||||
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
||||||
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
|
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
|
||||||
[Authorize]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
@@ -214,6 +214,10 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
if (reader.Read())
|
if (reader.Read())
|
||||||
{
|
{
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
if (!outSuccess)
|
||||||
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
@@ -221,8 +225,6 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
#region Create history
|
#region Create history
|
||||||
// ENV_UID, STATUS_ID, USER_ID,
|
// ENV_UID, STATUS_ID, USER_ID,
|
||||||
string sql_hist = @"
|
string sql_hist = @"
|
||||||
USE [DD_ECM]
|
|
||||||
|
|
||||||
DECLARE @OUT_SUCCESS bit;
|
DECLARE @OUT_SUCCESS bit;
|
||||||
|
|
||||||
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
||||||
@@ -244,6 +246,10 @@ public class EnvelopeReceiverController : ControllerBase
|
|||||||
if (reader.Read())
|
if (reader.Read())
|
||||||
{
|
{
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
if (!outSuccess)
|
||||||
|
_logger.LogWarning(
|
||||||
|
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
|
||||||
|
envelope.Uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net8.0</TargetFrameworks>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<PackageId>EnvelopeGenerator.Server</PackageId>
|
||||||
|
<Title></Title>
|
||||||
|
<Authors>Digital Data GmbH</Authors>
|
||||||
|
<Company>Digital Data GmbH</Company>
|
||||||
|
<Product>EnvelopeGenerator.Server</Product>
|
||||||
|
<Version>1.0.1-beta</Version>
|
||||||
|
<FileVersion>1.0.1.0</FileVersion>
|
||||||
|
<AssemblyVersion>1.0.1.0</AssemblyVersion>
|
||||||
|
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||||
|
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="DelegatingHandler"/> that forwards the incoming HTTP request's
|
||||||
|
/// <c>Cookie</c> header to all outgoing <see cref="System.Net.Http.HttpClient"/> calls
|
||||||
|
/// made by Blazor Server components.
|
||||||
|
///
|
||||||
|
/// Problem it solves:
|
||||||
|
/// Blazor Server runs on the server process. When a component calls an API endpoint
|
||||||
|
/// that requires cookie-based JWT authentication (AuthScheme.Sender), the HttpClient
|
||||||
|
/// does not automatically include the browser's cookies — those only travel with
|
||||||
|
/// browser-initiated requests. This handler copies the <c>Cookie</c> header from the
|
||||||
|
/// current <see cref="IHttpContextAccessor.HttpContext"/> into every outgoing request
|
||||||
|
/// so that the API's JwtBearer <c>OnMessageReceived</c> callback can extract the token.
|
||||||
|
///
|
||||||
|
/// Thread safety:
|
||||||
|
/// The handler is registered as Transient and is resolved per-request by the
|
||||||
|
/// IHttpClientFactory pipeline, so there is no shared state between requests.
|
||||||
|
/// </summary>
|
||||||
|
public class SenderAuthCookieHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cookieHeader = httpContextAccessor.HttpContext?.Request.Headers["Cookie"].ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookieHeader))
|
||||||
|
request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
|
||||||
|
|
||||||
|
return base.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ try
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
// Named HttpClient for internal API calls
|
// Named HttpClient for internal API calls
|
||||||
|
// SenderAuthCookieHandler forwards the browser's Cookie header so that
|
||||||
|
// Blazor Server components can call cookie-authenticated endpoints (AuthScheme.Sender).
|
||||||
|
builder.Services.AddTransient<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
|
||||||
builder.Services.AddHttpClient("EnvelopeGenerator.Server", (sp, client) =>
|
builder.Services.AddHttpClient("EnvelopeGenerator.Server", (sp, client) =>
|
||||||
{
|
{
|
||||||
var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
|
var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
|
||||||
@@ -79,7 +82,8 @@ try
|
|||||||
// Set base address to current host for SSR scenarios
|
// Set base address to current host for SSR scenarios
|
||||||
client.BaseAddress = new Uri($"{request.Scheme}://{request.Host}");
|
client.BaseAddress = new Uri($"{request.Scheme}://{request.Host}");
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.AddHttpMessageHandler<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
|
||||||
|
|
||||||
// CORS Policy
|
// CORS Policy
|
||||||
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
|
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<WebPublishMethod>Package</WebPublishMethod>
|
||||||
|
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||||
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
|
<SiteUrlToLaunchAfterPublish />
|
||||||
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
|
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
|
||||||
|
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
|
||||||
|
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||||
|
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
||||||
|
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<EnvironmentName>Production</EnvironmentName>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
225
EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md
Normal file
225
EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# EnvelopeGenerator.Server — Publish & Deployment Guide
|
||||||
|
|
||||||
|
## Inhaltsverzeichnis
|
||||||
|
|
||||||
|
1. [Unterschied zu einer normalen ASP.NET Core API](#unterschied-zu-einer-normalen-aspnet-core-api)
|
||||||
|
2. [Warum Self-Contained Publish?](#warum-self-contained-publish)
|
||||||
|
3. [Publish-Befehl (Terminal)](#publish-befehl-terminal)
|
||||||
|
4. [IIS-Konfiguration](#iis-konfiguration)
|
||||||
|
5. [Verzeichnisstruktur nach dem Publish](#verzeichnisstruktur-nach-dem-publish)
|
||||||
|
6. [Häufige Fehler](#häufige-fehler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unterschied zu einer normalen ASP.NET Core API
|
||||||
|
|
||||||
|
`EnvelopeGenerator.Server` ist **keine** gewöhnliche ASP.NET Core Web API. Es handelt sich um eine **Blazor Auto (Server + WebAssembly Hybrid)**-Anwendung.
|
||||||
|
|
||||||
|
| Merkmal | Normale ASP.NET Core API | EnvelopeGenerator.Server (Blazor Auto) |
|
||||||
|
|---|---|---|
|
||||||
|
| Projekttyp | `Microsoft.NET.Sdk.Web` | `Microsoft.NET.Sdk.Web` + WASM Client |
|
||||||
|
| Frontend | Keins / Razor Pages | Blazor Server + Blazor WASM |
|
||||||
|
| WASM-Komponente | Nein | Ja (`EnvelopeGenerator.Server.Client`) |
|
||||||
|
| Framework-DLL-Bindung | Tolerant gegenüber Runtime-Versionen | **Strikt**: WASM erwartet exakte Assembly-Versionen |
|
||||||
|
| Publish ohne .NET auf Zielserver | Nicht nötig (FDD reicht meist) | **Self-Contained Pflicht** empfohlen |
|
||||||
|
| IIS Application Pool | `.NET CLR v4.0` oder `No Managed Code` | **Zwingend: `No Managed Code`** |
|
||||||
|
| Publish-Paketgröße | ~5–20 MB | **~500 MB** (enthält .NET Runtime) |
|
||||||
|
| `web.config processPath` | `dotnet` + `.dll` | **`.\EnvelopeGenerator.Server.exe`** |
|
||||||
|
|
||||||
|
### Warum die WASM-Komponente den Unterschied macht
|
||||||
|
|
||||||
|
Die WASM-Seite der Anwendung (`EnvelopeGenerator.Server.Client`) bindet Assemblies wie
|
||||||
|
`Microsoft.Extensions.DependencyInjection.Abstractions` in einer **fest definierten Version**.
|
||||||
|
Bei einem **Framework-Dependent Deployment** werden diese Assemblies nicht mitgeliefert und
|
||||||
|
müssen auf dem Zielserver vorhanden sein — in der exakt passenden Version.
|
||||||
|
|
||||||
|
Fehlt die passende .NET-Runtime auf dem Zielserver, erscheint folgender Fehler beim Start:
|
||||||
|
|
||||||
|
```
|
||||||
|
Unhandled exception. System.IO.FileNotFoundException:
|
||||||
|
Could not load file or assembly
|
||||||
|
'Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum Self-Contained Publish?
|
||||||
|
|
||||||
|
Beim **Self-Contained Deployment** werden **alle benötigten .NET Runtime-DLLs** in das
|
||||||
|
Ausgabeverzeichnis kopiert. Die Anwendung ist damit vollständig unabhängig von der auf dem
|
||||||
|
Zielserver installierten .NET-Version.
|
||||||
|
|
||||||
|
| | Framework-Dependent | Self-Contained |
|
||||||
|
|---|---|---|
|
||||||
|
| .NET auf Zielserver nötig | Ja | **Nein** |
|
||||||
|
| Paketgröße | ~20 MB | ~500 MB |
|
||||||
|
| `runtimeconfig.json` | `frameworkVersion` vorhanden | `includedFrameworks` (Runtime eingebettet) |
|
||||||
|
| Fehleranfälligkeit auf Fremd-PC | Hoch | Minimal |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publish-Befehl (Terminal)
|
||||||
|
|
||||||
|
### Empfohlener Befehl (Self-Contained, win-x64)
|
||||||
|
|
||||||
|
```bat
|
||||||
|
dotnet publish EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj ^
|
||||||
|
-c Release ^
|
||||||
|
-f net8.0 ^
|
||||||
|
--self-contained true ^
|
||||||
|
--runtime win-x64 ^
|
||||||
|
-o .\publish-output
|
||||||
|
```
|
||||||
|
|
||||||
|
> Dieser Befehl muss vom **Solution-Root-Verzeichnis** aus ausgefuehrt werden.
|
||||||
|
> Alternativ: `publish.bat` im selben Verzeichnis wie diese README ausfuehren.
|
||||||
|
|
||||||
|
### Parameter-Erklaerung
|
||||||
|
|
||||||
|
| Parameter | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `-c Release` | Release-Konfiguration (optimiert, kein Debug-Code) |
|
||||||
|
| `-f net8.0` | Ziel-Framework explizit angeben (Pflicht, da `<TargetFrameworks>` mehrere Werte haben kann) |
|
||||||
|
| `--self-contained true` | Alle .NET Runtime-DLLs ins Ausgabeverzeichnis kopieren |
|
||||||
|
| `--runtime win-x64` | Zielplattform: Windows 64-Bit |
|
||||||
|
| `-o .\publish-output` | Ausgabeverzeichnis |
|
||||||
|
|
||||||
|
### Überprüfung nach dem Publish
|
||||||
|
|
||||||
|
Nach erfolgreichem Publish folgende Dateien im Ausgabeverzeichnis prüfen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Diese Dateien MÜSSEN vorhanden sein (Self-Contained-Nachweis):
|
||||||
|
Test-Path ".\publish-output\hostfxr.dll" # .NET Host
|
||||||
|
Test-Path ".\publish-output\coreclr.dll" # .NET Core Runtime
|
||||||
|
Test-Path ".\publish-output\Microsoft.Extensions.DependencyInjection.Abstractions.dll"
|
||||||
|
Test-Path ".\publish-output\EnvelopeGenerator.Server.exe"
|
||||||
|
Test-Path ".\publish-output\web.config"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Ergebnisse müssen `True` sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IIS-Konfiguration
|
||||||
|
|
||||||
|
> **WICHTIG:** Diese Einstellungen unterscheiden sich von einer normalen ASP.NET Core API
|
||||||
|
> und sind zwingend erforderlich.
|
||||||
|
|
||||||
|
### 1. Application Pool — `No Managed Code`
|
||||||
|
|
||||||
|
ASP.NET Core (und damit auch Blazor) verwaltet seinen eigenen Runtime-Lifecycle.
|
||||||
|
IIS darf **keinen** .NET CLR-Managed-Code-Kontext aktivieren.
|
||||||
|
|
||||||
|
**Einstellung:**
|
||||||
|
|
||||||
|
```
|
||||||
|
IIS Manager
|
||||||
|
→ Application Pools
|
||||||
|
→ [Pool-Name der Anwendung] → Basic Settings
|
||||||
|
→ .NET CLR Version: "No Managed Code" ← ZWINGEND
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Fehler bei falscher Einstellung:** HTTP 500.30 — ASP.NET Core app failed to start
|
||||||
|
> (sc-win32-status: 574 in IIS-Logs)
|
||||||
|
|
||||||
|
### 2. ASP.NET Core Module V2
|
||||||
|
|
||||||
|
Das IIS-Modul `AspNetCoreModuleV2` muss installiert sein.
|
||||||
|
Es wird über das **.NET Hosting Bundle** mitgeliefert.
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
```
|
||||||
|
IIS Manager → Modules → "AspNetCoreModuleV2" vorhanden?
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls nicht installiert: [.NET 8 Hosting Bundle herunterladen](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
|
||||||
|
und nach der Installation IIS neu starten:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
net stop was /y && net start w3svc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. web.config — Korrekte Konfiguration für Self-Contained
|
||||||
|
|
||||||
|
Nach dem Publish wird `web.config` automatisch generiert. Für Self-Contained muss sie so aussehen:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<location path="." inheritInChildApplications="false">
|
||||||
|
<system.webServer>
|
||||||
|
<handlers>
|
||||||
|
<add name="aspNetCore" path="*" verb="*"
|
||||||
|
modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||||
|
</handlers>
|
||||||
|
<aspNetCore processPath=".\EnvelopeGenerator.Server.exe"
|
||||||
|
stdoutLogEnabled="false"
|
||||||
|
stdoutLogFile=".\logs\stdout"
|
||||||
|
hostingModel="inprocess" />
|
||||||
|
</system.webServer>
|
||||||
|
</location>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kritische Unterschiede zu Framework-Dependent:**
|
||||||
|
|
||||||
|
| Eigenschaft | Framework-Dependent (FALSCH) | Self-Contained (RICHTIG) |
|
||||||
|
|---|---|---|
|
||||||
|
| `processPath` | `dotnet` | `.\EnvelopeGenerator.Server.exe` |
|
||||||
|
| `arguments` | `.\EnvelopeGenerator.Server.dll` | *(leer oder weggelassen)* |
|
||||||
|
|
||||||
|
### 4. Berechtigungen für das `logs`-Verzeichnis
|
||||||
|
|
||||||
|
Wenn `stdoutLogEnabled="true"` gesetzt wird (zur Fehlerdiagnose), muss das `logs`-Verzeichnis
|
||||||
|
existieren und der IIS-Prozess muss Schreibrechte haben:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Path "C:\inetpub\wwwroot\<App-Pfad>\logs" -Force
|
||||||
|
icacls "C:\inetpub\wwwroot\<App-Pfad>\logs" /grant "IIS_IUSRS:(OI)(CI)F"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ohne dieses Verzeichnis kann die Anwendung bei aktiviertem Logging **nicht starten**.
|
||||||
|
|
||||||
|
### 5. Application Pool Recycle nach Deployment
|
||||||
|
|
||||||
|
Nach jedem Deployment den Application Pool neu starten:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
# IIS Manager → Application Pools → [Pool] → Recycle
|
||||||
|
# oder per Kommandozeile (als Administrator):
|
||||||
|
%windir%\system32\inetsrv\appcmd recycle apppool /apppool.name:"<Pool-Name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verzeichnisstruktur nach dem Publish
|
||||||
|
|
||||||
|
```
|
||||||
|
publish-output\
|
||||||
|
├── EnvelopeGenerator.Server.exe ← Startpunkt (Self-Contained)
|
||||||
|
├── EnvelopeGenerator.Server.dll ← Managed Assembly
|
||||||
|
├── EnvelopeGenerator.Server.runtimeconfig.json
|
||||||
|
├── EnvelopeGenerator.Server.deps.json
|
||||||
|
├── web.config ← IIS-Konfiguration (auto-generiert)
|
||||||
|
├── hostfxr.dll ← .NET Host (Self-Contained-Nachweis)
|
||||||
|
├── coreclr.dll ← .NET Core Runtime
|
||||||
|
├── Microsoft.Extensions.*.dll ← Framework-DLLs (jetzt enthalten!)
|
||||||
|
├── DevExpress.*.dll ← UI-Komponenten
|
||||||
|
├── wwwroot\ ← Statische Web-Assets
|
||||||
|
│ ├── _framework\ ← WASM-Binaries
|
||||||
|
│ └── ...
|
||||||
|
└── logs\ ← Stdout-Logs (manuell anlegen!)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Fehler
|
||||||
|
|
||||||
|
| Fehler | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| `FileNotFoundException: Microsoft.Extensions.DependencyInjection.Abstractions` | Framework-Dependent Publish auf Server ohne .NET 8 | Self-Contained Publish verwenden |
|
||||||
|
| `HTTP 500.30` in IIS | App startet nicht | Application Pool auf `No Managed Code` setzen |
|
||||||
|
| `HTTP 500.30` + `sc-win32-status: 574` | App Pool falsch oder `AspNetCoreModuleV2` fehlt | Pool prüfen + Hosting Bundle installieren |
|
||||||
|
| App startet per `.exe`, aber nicht in IIS | `web.config` hat noch `processPath="dotnet"` | `web.config` auf `.\EnvelopeGenerator.Server.exe` korrigieren |
|
||||||
|
| Logs-Verzeichnis fehlt → App startet nicht | `stdoutLogEnabled="true"` aber `logs\` existiert nicht | `logs\`-Ordner anlegen + IIS_IUSRS Schreibrecht geben |
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.9",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,8 +308,8 @@ article {
|
|||||||
.home-btn-primary {
|
.home-btn-primary {
|
||||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff !important;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: filter 0.15s, box-shadow 0.15s;
|
transition: filter 0.15s, box-shadow 0.15s;
|
||||||
box-shadow: 0 2px 10px rgba(44,62,80,0.30);
|
box-shadow: 0 2px 10px rgba(44,62,80,0.30);
|
||||||
|
|||||||
@@ -52,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pdf-editor-wrapper {
|
.pdf-editor-wrapper {
|
||||||
position: relative;
|
width: 100%;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-editor-pdf-viewer {
|
.sender-editor-pdf-viewer {
|
||||||
@@ -61,6 +61,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-editor-pdf-viewer .dxbl-toolbar {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-editor-pdf-viewer .dxbl-toolbar-left {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.sender-editor-pdf-viewer .dxbrv-document-surface {
|
.sender-editor-pdf-viewer .dxbrv-document-surface {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,92 +1,95 @@
|
|||||||
window.envelopeEditor = {
|
window.envelopeEditor = {
|
||||||
_overlaySyncState: {},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns click coordinates relative to the overlay element.
|
* Returns the click position normalised to [0,1] relative to the rendered PDF page
|
||||||
* @param {string} overlayId - The id of the overlay div
|
* element inside DxPdfViewer (or DxReportViewer as fallback).
|
||||||
* @param {number} clientX - MouseEventArgs.ClientX from Blazor
|
*
|
||||||
* @param {number} clientY - MouseEventArgs.ClientY from Blazor
|
* Normalising means the result is independent of zoom level: no matter how much the
|
||||||
* @returns {{ relX, relY, containerW, containerH }}
|
* user has zoomed in/out, the same physical spot on the PDF will always yield the same
|
||||||
|
* normalised value. C# multiplies by the page's point dimensions to get PDF points.
|
||||||
|
*
|
||||||
|
* @param {string} viewerCssClass - CssClass set on DxPdfViewer (e.g. "sender-editor-pdf-viewer")
|
||||||
|
* @param {number} clientX - MouseEvent.clientX from Blazor
|
||||||
|
* @param {number} clientY - MouseEvent.clientY from Blazor
|
||||||
|
* @returns {{ normX, normY, pageIndex } | null}
|
||||||
|
* normX / normY : 0..1 fraction within the page element
|
||||||
|
* pageIndex : 0-based index of the page the click landed on (-1 if not found)
|
||||||
*/
|
*/
|
||||||
getClickCoords: function (overlayId, clientX, clientY) {
|
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
|
||||||
const el = document.getElementById(overlayId);
|
|
||||||
if (!el) return null;
|
// Find the viewer root element
|
||||||
|
const viewer = document.querySelector('.' + viewerCssClass);
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Candidate page elements (ordered by preference) ---
|
||||||
|
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
|
||||||
|
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
|
||||||
|
const pageSelectors = [
|
||||||
|
'.dxbl-pdfv-page',
|
||||||
|
'.dxbrv-report-preview-page',
|
||||||
|
'.dxbrv-report-preview-content-img',
|
||||||
|
];
|
||||||
|
|
||||||
|
let allPages = [];
|
||||||
|
for (const sel of pageSelectors) {
|
||||||
|
const found = Array.from(viewer.querySelectorAll(sel));
|
||||||
|
if (found.length > 0) {
|
||||||
|
allPages = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPages.length === 0) {
|
||||||
|
console.warn('[envelopeEditor] no page elements found inside viewer');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Find which page the click landed on ---
|
||||||
|
// Walk through all pages; pick the one whose bounding rect contains the click.
|
||||||
|
// If none contains it exactly, fall back to the page closest vertically.
|
||||||
|
let targetPage = null;
|
||||||
|
let targetIndex = -1;
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < allPages.length; i++) {
|
||||||
|
const rect = allPages[i].getBoundingClientRect();
|
||||||
|
|
||||||
|
// Exact hit
|
||||||
|
if (clientX >= rect.left && clientX <= rect.right &&
|
||||||
|
clientY >= rect.top && clientY <= rect.bottom) {
|
||||||
|
targetPage = allPages[i];
|
||||||
|
targetIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track closest page (vertical centre distance) as fallback
|
||||||
|
const cy = rect.top + rect.height / 2;
|
||||||
|
const dist = Math.abs(clientY - cy);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
targetPage = allPages[i];
|
||||||
|
targetIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPage) return null;
|
||||||
|
|
||||||
|
const pageRect = targetPage.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Clamp click inside page boundaries before normalising
|
||||||
|
const clampedX = Math.max(pageRect.left, Math.min(clientX, pageRect.right));
|
||||||
|
const clampedY = Math.max(pageRect.top, Math.min(clientY, pageRect.bottom));
|
||||||
|
|
||||||
|
const normX = (clampedX - pageRect.left) / pageRect.width;
|
||||||
|
const normY = (clampedY - pageRect.top) / pageRect.height;
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
return {
|
return {
|
||||||
relX: clientX - rect.left,
|
normX: normX,
|
||||||
relY: clientY - rect.top,
|
normY: normY,
|
||||||
containerW: rect.width,
|
pageIndex: targetIndex
|
||||||
containerH: rect.height
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
|
|
||||||
syncOverlayToPage: function (wrapperId, overlayId) {
|
|
||||||
const wrapper = document.getElementById(wrapperId);
|
|
||||||
const overlay = document.getElementById(overlayId);
|
|
||||||
|
|
||||||
if (!wrapper || !overlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = window.envelopeEditor._overlaySyncState[overlayId];
|
|
||||||
if (existing) {
|
|
||||||
return existing.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
const findTarget = (currentWrapper) => {
|
|
||||||
const page = currentWrapper.querySelector(".dxbrv-report-preview-content");
|
|
||||||
if (page) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentWrapper.querySelector(".dxbrv-report-preview-content-img") ||
|
|
||||||
currentWrapper.querySelector("img.dxbrv-report-preview-content-img") ||
|
|
||||||
currentWrapper.querySelector(".dxbrv-document-surface img");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
const currentWrapper = document.getElementById(wrapperId);
|
|
||||||
const currentOverlay = document.getElementById(overlayId);
|
|
||||||
|
|
||||||
if (!currentWrapper || !currentOverlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = findTarget(currentWrapper);
|
|
||||||
if (!target) {
|
|
||||||
currentOverlay.style.left = "0px";
|
|
||||||
currentOverlay.style.top = "0px";
|
|
||||||
currentOverlay.style.width = "0px";
|
|
||||||
currentOverlay.style.height = "0px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapperRect = currentWrapper.getBoundingClientRect();
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
|
||||||
|
|
||||||
currentOverlay.style.left = `${targetRect.left - wrapperRect.left + currentWrapper.scrollLeft}px`;
|
|
||||||
currentOverlay.style.top = `${targetRect.top - wrapperRect.top + currentWrapper.scrollTop}px`;
|
|
||||||
currentOverlay.style.width = `${targetRect.width}px`;
|
|
||||||
currentOverlay.style.height = `${targetRect.height}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleSync = () => requestAnimationFrame(sync);
|
|
||||||
|
|
||||||
const observer = new MutationObserver(scheduleSync);
|
|
||||||
observer.observe(wrapper, { childList: true, subtree: true, attributes: true });
|
|
||||||
|
|
||||||
wrapper.addEventListener("scroll", scheduleSync, { passive: true });
|
|
||||||
window.addEventListener("resize", scheduleSync);
|
|
||||||
|
|
||||||
window.envelopeEditor._overlaySyncState[overlayId] = {
|
|
||||||
sync,
|
|
||||||
observer
|
|
||||||
};
|
|
||||||
|
|
||||||
sync();
|
|
||||||
setTimeout(sync, 50);
|
|
||||||
setTimeout(sync, 150);
|
|
||||||
setTimeout(sync, 400);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
96
EnvelopeGenerator.Server/publish.bat
Normal file
96
EnvelopeGenerator.Server/publish.bat
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo EnvelopeGenerator.Server - Self-Contained Publish
|
||||||
|
echo Target: win-x64 / .NET 8 / Release
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Must be run from the solution root directory.
|
||||||
|
REM This file is located under: EnvelopeGenerator.Server\
|
||||||
|
|
||||||
|
set PROJECT=EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
|
||||||
|
set OUTPUT=publish-output
|
||||||
|
set RID=win-x64
|
||||||
|
set FRAMEWORK=net8.0
|
||||||
|
|
||||||
|
echo [1/3] Cleaning previous publish output...
|
||||||
|
if exist "%OUTPUT%" (
|
||||||
|
rmdir /s /q "%OUTPUT%"
|
||||||
|
echo Removed: %OUTPUT%
|
||||||
|
) else (
|
||||||
|
echo Nothing to clean.
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [2/3] Publishing...
|
||||||
|
echo Project : %PROJECT%
|
||||||
|
echo Output : %OUTPUT%
|
||||||
|
echo RID : %RID%
|
||||||
|
echo Framework: %FRAMEWORK%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
dotnet publish "%PROJECT%" ^
|
||||||
|
-c Release ^
|
||||||
|
-f %FRAMEWORK% ^
|
||||||
|
--self-contained true ^
|
||||||
|
--runtime %RID% ^
|
||||||
|
-o "%OUTPUT%"
|
||||||
|
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] Publish failed! ERRORLEVEL=%ERRORLEVEL%
|
||||||
|
pause
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [3/3] Verifying output...
|
||||||
|
|
||||||
|
set PASS=1
|
||||||
|
|
||||||
|
if not exist "%OUTPUT%\EnvelopeGenerator.Server.exe" (
|
||||||
|
echo [FAIL] EnvelopeGenerator.Server.exe not found!
|
||||||
|
set PASS=0
|
||||||
|
)
|
||||||
|
if not exist "%OUTPUT%\hostfxr.dll" (
|
||||||
|
echo [FAIL] hostfxr.dll not found! (Not a self-contained publish?)
|
||||||
|
set PASS=0
|
||||||
|
)
|
||||||
|
if not exist "%OUTPUT%\coreclr.dll" (
|
||||||
|
echo [FAIL] coreclr.dll not found! (Not a self-contained publish?)
|
||||||
|
set PASS=0
|
||||||
|
)
|
||||||
|
if not exist "%OUTPUT%\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
|
||||||
|
echo [FAIL] Microsoft.Extensions.DependencyInjection.Abstractions.dll not found!
|
||||||
|
set PASS=0
|
||||||
|
)
|
||||||
|
if not exist "%OUTPUT%\web.config" (
|
||||||
|
echo [FAIL] web.config not found!
|
||||||
|
set PASS=0
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%PASS%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo PUBLISH SUCCEEDED
|
||||||
|
echo Output folder: %~dp0%OUTPUT%
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
echo Next steps:
|
||||||
|
echo 1. Copy the contents of '%OUTPUT%' to the IIS application directory
|
||||||
|
echo 2. Set the IIS Application Pool to 'No Managed Code'
|
||||||
|
echo 3. Recycle the Application Pool
|
||||||
|
echo.
|
||||||
|
) else (
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo PUBLISH COMPLETED BUT VERIFICATION FAILED
|
||||||
|
echo Review the FAIL messages above.
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
|
endlocal
|
||||||
41
EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj
Normal file
41
EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net4.6.2</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-EnvelopeGenerator.Service-816256a4-c636-4313-92c0-b62b04040c2b</UserSecretsId>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
|
||||||
|
<PackageReference Include="NLog" Version="5.3.1" />
|
||||||
|
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- DigitalData & Quartz: local packages, must match CommonServices versions exactly -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="DigitalData.Modules.Database">
|
||||||
|
<HintPath>..\packages\DigitalData.Modules.Database.2.3.5.4\lib\net462\DigitalData.Modules.Database.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="DigitalData.Modules.Encryption">
|
||||||
|
<HintPath>..\packages\DigitalData.Modules.Encryption.1.3.1\lib\net462\DigitalData.Modules.Encryption.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="DigitalData.Modules.Logging">
|
||||||
|
<HintPath>..\packages\DigitalData.Modules.Logging.2.6.5\lib\net462\DigitalData.Modules.Logging.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<!-- Quartz 3.8.0 <20> must match CommonServices (same AppDomain, one version) -->
|
||||||
|
<Reference Include="Quartz">
|
||||||
|
<HintPath>..\packages\Quartz.3.8.0\lib\net462\Quartz.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.CommonServices\EnvelopeGenerator.CommonServices.vbproj" />
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
||||||
28
EnvelopeGenerator.Service/LogConfigFactory.cs
Normal file
28
EnvelopeGenerator.Service/LogConfigFactory.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using DigitalData.Modules.Logging;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="LogConfig"/> from <see cref="ServiceConfig"/>.
|
||||||
|
/// Required because CommonServices jobs have a hard dependency on <see cref="LogConfig"/>
|
||||||
|
/// in their Quartz <c>JobDataMap</c>.
|
||||||
|
/// Log path mirrors the legacy behavior: <c><BaseDirectory>\Log</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LogConfigFactory
|
||||||
|
{
|
||||||
|
public static LogConfig Create(ServiceConfig config)
|
||||||
|
{
|
||||||
|
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
|
||||||
|
|
||||||
|
var logConfig = new LogConfig(
|
||||||
|
LogConfig.PathType.CustomPath,
|
||||||
|
logPath,
|
||||||
|
null,
|
||||||
|
"Digital Data",
|
||||||
|
"EnvelopeGenerator.Service");
|
||||||
|
|
||||||
|
logConfig.Debug = config.Debug;
|
||||||
|
|
||||||
|
return logConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
EnvelopeGenerator.Service/MIGRATION_PLAN.md
Normal file
315
EnvelopeGenerator.Service/MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
|
||||||
|
**Revision v3** — Full deep analysis complete
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
|
||||||
|
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
|
||||||
|
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deep Analysis Findings (vs. v1/v2)
|
||||||
|
|
||||||
|
### Finding 1 — Connection String: Plain Text, No Encryption Required
|
||||||
|
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
|
||||||
|
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
|
||||||
|
for plain-text strings — no wrapping/encryption step is needed.
|
||||||
|
|
||||||
|
**Resolution**: The connection string is read from `IConfiguration` via the standard
|
||||||
|
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
|
||||||
|
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
|
||||||
|
`EncryptConnectionString()` call is made anywhere in the new service.
|
||||||
|
|
||||||
|
### Finding 2 — Dual TempFiles Architecture
|
||||||
|
There are **two independent** `TempFiles` classes:
|
||||||
|
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
|
||||||
|
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
|
||||||
|
**We do not touch this.**
|
||||||
|
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
|
||||||
|
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
|
||||||
|
|
||||||
|
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
|
||||||
|
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
|
||||||
|
```vb
|
||||||
|
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
|
||||||
|
Logger = LogConfig.GetLogger()
|
||||||
|
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
|
||||||
|
```
|
||||||
|
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
|
||||||
|
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
|
||||||
|
|
||||||
|
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
|
||||||
|
build a `LogConfig` from `ServiceConfig` values:
|
||||||
|
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
|
||||||
|
- `Debug` = `ServiceConfig.Debug`
|
||||||
|
- Application name = `"EnvelopeGenerator.Service"`
|
||||||
|
|
||||||
|
### Finding 4 — ProjectInstaller is Obsolete
|
||||||
|
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
|
||||||
|
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
|
||||||
|
No equivalent is needed in the new project.
|
||||||
|
|
||||||
|
### Finding 5 — GDPicture License SQL
|
||||||
|
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
|
||||||
|
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
|
||||||
|
with a descriptive exception (matching legacy behavior).
|
||||||
|
|
||||||
|
### Finding 6 — Quartz Version Mismatch
|
||||||
|
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
|
||||||
|
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
|
||||||
|
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
|
||||||
|
|
||||||
|
- Add **NuGet packages**:
|
||||||
|
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
|
||||||
|
- `NLog.Extensions.Logging` (latest net462-compatible)
|
||||||
|
- `Quartz` (**3.8.0** — must match CommonServices)
|
||||||
|
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
|
||||||
|
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
|
||||||
|
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration (`ServiceConfig.cs`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ServiceConfig
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
public bool Debug { get; set; } = false;
|
||||||
|
public int IntervalInMin { get; set; } = 1;
|
||||||
|
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
|
||||||
|
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
|
||||||
|
|
||||||
|
`appsettings.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": ""
|
||||||
|
},
|
||||||
|
"ServiceConfig": {
|
||||||
|
"Debug": false,
|
||||||
|
"IntervalInMin": 1,
|
||||||
|
"PDFBurnerParams": {
|
||||||
|
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
|
||||||
|
"TopMargin": 0.1,
|
||||||
|
"YOffset": -0.3,
|
||||||
|
"FontName": "Arial",
|
||||||
|
"FontSize": 8,
|
||||||
|
"FontStyle": 2,
|
||||||
|
"TruncationSuffix": "..",
|
||||||
|
"TextMaxWidths": {
|
||||||
|
"position": 1.41,
|
||||||
|
"city": 1.41,
|
||||||
|
"date": 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
|
||||||
|
> All values mirror `PDFBurnerParams.vb` defaults exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. NLog Setup
|
||||||
|
|
||||||
|
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
|
||||||
|
layout with timestamp + level + logger + message + exception.
|
||||||
|
|
||||||
|
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
|
||||||
|
```csharp
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddNLog("nlog.config");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. LogConfigFactory (`LogConfigFactory.cs`)
|
||||||
|
|
||||||
|
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static class LogConfigFactory
|
||||||
|
{
|
||||||
|
public static LogConfig Create(ServiceConfig config)
|
||||||
|
{
|
||||||
|
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
|
||||||
|
var logConfig = new LogConfig(
|
||||||
|
LogConfig.PathType.CustomPath, logPath, null,
|
||||||
|
"Digital Data", "EnvelopeGenerator.Service");
|
||||||
|
logConfig.Debug = config.Debug;
|
||||||
|
return logConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
|
||||||
|
|
||||||
|
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal class QuartzLogProvider : ILogProvider
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
public QuartzLogProvider(ILogger logger) => _logger = logger;
|
||||||
|
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
|
||||||
|
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. TempFiles (`TempFiles.cs`) — Service-level only
|
||||||
|
|
||||||
|
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
|
||||||
|
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
|
||||||
|
- `CleanUp()` ? delete the directory on service stop
|
||||||
|
|
||||||
|
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
|
||||||
|
> inside each job via `LogConfig`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Schedulers
|
||||||
|
|
||||||
|
### `Schedulers/SchedulerFinishEnvelope.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class SchedulerFinishEnvelope
|
||||||
|
{
|
||||||
|
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
|
||||||
|
// Start(licenseKey, logConfig):
|
||||||
|
// - Build JobDataMap:
|
||||||
|
// [GDPICTURE] = licenseKey
|
||||||
|
// [LOGCONFIG] = logConfig ? LogConfigFactory output
|
||||||
|
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
|
||||||
|
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
|
||||||
|
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
|
||||||
|
// - Register QuartzLogProvider
|
||||||
|
// Stop(): Scheduler.Shutdown()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class SchedulerEnvelopeTaskApi
|
||||||
|
{
|
||||||
|
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
|
||||||
|
// Start(logConfig):
|
||||||
|
// - Build JobDataMap:
|
||||||
|
// [LOGCONFIG] = logConfig
|
||||||
|
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
|
||||||
|
// - Schedule APIEnvelopeJob with Quartz (interval from config)
|
||||||
|
// - Register QuartzLogProvider
|
||||||
|
// Stop(): Scheduler.Shutdown()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Worker (`Worker.cs`)
|
||||||
|
|
||||||
|
Full lifecycle replacing the placeholder:
|
||||||
|
|
||||||
|
```
|
||||||
|
ExecuteAsync:
|
||||||
|
1. Read ServiceConfig from IOptions<ServiceConfig>
|
||||||
|
2. Validate ConnectionString ? throw if empty
|
||||||
|
3. Build LogConfig via LogConfigFactory
|
||||||
|
4. Connect to DB (plain connection string — Worker-level only)
|
||||||
|
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
|
||||||
|
? throw if null/empty (matches legacy behavior)
|
||||||
|
6. TempFiles.Create()
|
||||||
|
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
|
||||||
|
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
|
||||||
|
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
|
||||||
|
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
|
||||||
|
|
||||||
|
StopAsync override:
|
||||||
|
11. await SchedulerFinishEnvelope.Stop()
|
||||||
|
12. await SchedulerEnvelopeTaskApi.Stop()
|
||||||
|
13. TempFiles.CleanUp()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Program.cs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddNLog("nlog.config");
|
||||||
|
|
||||||
|
builder.Services.Configure<ServiceConfig>(
|
||||||
|
builder.Configuration.GetSection("ServiceConfig"));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<TempFiles>();
|
||||||
|
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
|
||||||
|
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
|
||||||
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Files to Create / Modify
|
||||||
|
|
||||||
|
| File | Action | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
|
||||||
|
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
|
||||||
|
| `Worker.cs` | **Modify** | Full service lifecycle |
|
||||||
|
| `ServiceConfig.cs` | **Create** | |
|
||||||
|
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
|
||||||
|
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
|
||||||
|
| `QuartzLogProvider.cs` | **Create** | |
|
||||||
|
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
|
||||||
|
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
|
||||||
|
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
|
||||||
|
| `nlog.config` | **Create** | |
|
||||||
|
|
||||||
|
**Not created** (obsolete in new architecture):
|
||||||
|
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
|
||||||
|
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
|
||||||
|
- ~~`Service.resx`~~ ? not applicable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Key Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
|
||||||
|
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
|
||||||
|
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
|
||||||
|
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
|
||||||
|
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
|
||||||
|
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
|
||||||
|
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
|
||||||
|
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval Required
|
||||||
|
|
||||||
|
> Please review this plan and confirm before implementation begins.
|
||||||
|
|
||||||
41
EnvelopeGenerator.Service/Program.cs
Normal file
41
EnvelopeGenerator.Service/Program.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using EnvelopeGenerator.Service;
|
||||||
|
using EnvelopeGenerator.Service.Schedulers;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NLog.Extensions.Logging;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
// NLog
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddNLog("nlog.config");
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
builder.Services.Configure<ServiceConfig>(
|
||||||
|
builder.Configuration.GetSection("ServiceConfig"));
|
||||||
|
|
||||||
|
// Resolve connection string once for singleton registrations
|
||||||
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||||
|
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
|
||||||
|
|
||||||
|
// Services
|
||||||
|
builder.Services.AddSingleton<TempFiles>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<SchedulerFinishEnvelope>(sp => new SchedulerFinishEnvelope(
|
||||||
|
sp.GetRequiredService<ILogger<SchedulerFinishEnvelope>>(),
|
||||||
|
sp.GetRequiredService<IOptions<ServiceConfig>>(),
|
||||||
|
connectionString));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>(sp => new SchedulerEnvelopeTaskApi(
|
||||||
|
sp.GetRequiredService<ILogger<SchedulerEnvelopeTaskApi>>(),
|
||||||
|
sp.GetRequiredService<IOptions<ServiceConfig>>(),
|
||||||
|
connectionString));
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
|
// Windows Service (SCM) support — replaces legacy ProjectInstaller/installutil
|
||||||
|
builder.Services.AddWindowsService(options =>
|
||||||
|
options.ServiceName = "EnvelopeGenerator.Service");
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>bin\Release\net4.6.2\publish\</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<_TargetId>Folder</_TargetId>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
12
EnvelopeGenerator.Service/Properties/launchSettings.json
Normal file
12
EnvelopeGenerator.Service/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"EnvelopeGenerator.Service": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
EnvelopeGenerator.Service/QuartzLogProvider.cs
Normal file
60
EnvelopeGenerator.Service/QuartzLogProvider.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using Quartz.Logging;
|
||||||
|
using QuartzLogLevel = Quartz.Logging.LogLevel;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges Quartz's <see cref="ILogProvider"/> to <see cref="ILogger"/>.
|
||||||
|
/// Mirrors the legacy <c>LogProvider.vb</c> behavior.
|
||||||
|
/// <c>OpenNestedContext</c> and <c>OpenMappedContext</c> are not implemented (same as legacy).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class QuartzLogProvider : ILogProvider
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public QuartzLogProvider(ILogger logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Logger GetLogger(string name)
|
||||||
|
{
|
||||||
|
return (level, func, exception, parameters) =>
|
||||||
|
{
|
||||||
|
if (exception != null)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, exception.Message);
|
||||||
|
}
|
||||||
|
else if (func != null)
|
||||||
|
{
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case QuartzLogLevel.Trace:
|
||||||
|
case QuartzLogLevel.Debug:
|
||||||
|
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
|
||||||
|
_logger.LogDebug(func(), parameters);
|
||||||
|
break;
|
||||||
|
case QuartzLogLevel.Info:
|
||||||
|
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information))
|
||||||
|
_logger.LogInformation(func(), parameters);
|
||||||
|
break;
|
||||||
|
case QuartzLogLevel.Warn:
|
||||||
|
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Warning))
|
||||||
|
_logger.LogWarning(func(), parameters);
|
||||||
|
break;
|
||||||
|
case QuartzLogLevel.Error:
|
||||||
|
case QuartzLogLevel.Fatal:
|
||||||
|
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Error))
|
||||||
|
_logger.LogError(func(), parameters);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable OpenNestedContext(string message) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public IDisposable OpenMappedContext(string key, object value, bool destructure = false) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using DigitalData.Modules.Logging;
|
||||||
|
using EnvelopeGenerator.CommonServices.Jobs;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Quartz;
|
||||||
|
using Quartz.Logging;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service.Schedulers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules <see cref="APIEnvelopeJob"/> via Quartz.
|
||||||
|
/// C# rewrite of the legacy <c>Scheduler_Envelopetask_API.vb</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulerEnvelopeTaskApi
|
||||||
|
{
|
||||||
|
private readonly ILogger<SchedulerEnvelopeTaskApi> _logger;
|
||||||
|
private readonly ServiceConfig _config;
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
private IScheduler? _scheduler;
|
||||||
|
|
||||||
|
private const string JobName = "SendInvitationMailsAPI";
|
||||||
|
|
||||||
|
public SchedulerEnvelopeTaskApi(
|
||||||
|
ILogger<SchedulerEnvelopeTaskApi> logger,
|
||||||
|
IOptions<ServiceConfig> config,
|
||||||
|
string connectionString)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_config = config.Value;
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start(LogConfig logConfig)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Starting SchedulerEnvelopeTaskApi...");
|
||||||
|
|
||||||
|
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
|
||||||
|
|
||||||
|
_scheduler = await SchedulerBuilder
|
||||||
|
.Create(new NameValueCollection())
|
||||||
|
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
|
||||||
|
.BuildScheduler();
|
||||||
|
|
||||||
|
var jobKey = new JobKey(JobName);
|
||||||
|
|
||||||
|
var jobData = new JobDataMap
|
||||||
|
{
|
||||||
|
{ Value.LOGCONFIG, logConfig },
|
||||||
|
{ Value.DATABASE, _connectionString }
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<APIEnvelopeJob>()
|
||||||
|
.UsingJobData(jobData)
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.ForJob(jobKey)
|
||||||
|
.WithIdentity($"{JobName}-trigger")
|
||||||
|
.WithSimpleSchedule(s => s
|
||||||
|
.RepeatForever()
|
||||||
|
.WithIntervalInMinutes(_config.IntervalInMin))
|
||||||
|
.StartNow()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_logger.LogDebug("{JobName}-trigger initialized", JobName);
|
||||||
|
|
||||||
|
await _scheduler.ScheduleJob(job, trigger);
|
||||||
|
_logger.LogDebug("{JobName} scheduled", JobName);
|
||||||
|
|
||||||
|
await _scheduler.Start();
|
||||||
|
_logger.LogInformation("{JobName} started", JobName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SchedulerEnvelopeTaskApi failed to start");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Stop()
|
||||||
|
{
|
||||||
|
if (_scheduler is null) return;
|
||||||
|
_logger.LogInformation("Stopping SchedulerEnvelopeTaskApi...");
|
||||||
|
await _scheduler.Shutdown();
|
||||||
|
_logger.LogInformation("SchedulerEnvelopeTaskApi stopped!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using DigitalData.Modules.Logging;
|
||||||
|
using EnvelopeGenerator.CommonServices.Jobs;
|
||||||
|
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Quartz;
|
||||||
|
using Quartz.Logging;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service.Schedulers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules <see cref="FinalizeDocumentJob"/> via Quartz.
|
||||||
|
/// C# rewrite of the legacy <c>Scheduler_FinishEnvelope.vb</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulerFinishEnvelope
|
||||||
|
{
|
||||||
|
private readonly ILogger<SchedulerFinishEnvelope> _logger;
|
||||||
|
private readonly ServiceConfig _config;
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
private IScheduler? _scheduler;
|
||||||
|
|
||||||
|
private const string JobName = "CertificateDocumentJob";
|
||||||
|
|
||||||
|
public SchedulerFinishEnvelope(
|
||||||
|
ILogger<SchedulerFinishEnvelope> logger,
|
||||||
|
IOptions<ServiceConfig> config,
|
||||||
|
string connectionString)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_config = config.Value;
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start(string licenseKey, LogConfig logConfig)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Starting SchedulerFinishEnvelope...");
|
||||||
|
|
||||||
|
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
|
||||||
|
|
||||||
|
_scheduler = await SchedulerBuilder
|
||||||
|
.Create(new NameValueCollection())
|
||||||
|
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
|
||||||
|
.BuildScheduler();
|
||||||
|
|
||||||
|
var jobKey = new JobKey(JobName);
|
||||||
|
|
||||||
|
var jobData = new JobDataMap
|
||||||
|
{
|
||||||
|
{ Value.GDPICTURE, licenseKey },
|
||||||
|
{ Value.LOGCONFIG, logConfig },
|
||||||
|
{ Value.DATABASE, _connectionString },
|
||||||
|
{ Value.PDF_BURNER_PARAMS, _config.PDFBurnerParams }
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<FinalizeDocumentJob>()
|
||||||
|
.UsingJobData(jobData)
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.ForJob(jobKey)
|
||||||
|
.WithIdentity($"{JobName}-trigger")
|
||||||
|
.WithSimpleSchedule(s => s
|
||||||
|
.RepeatForever()
|
||||||
|
.WithIntervalInMinutes(_config.IntervalInMin))
|
||||||
|
.StartNow()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_logger.LogDebug("Initialized Trigger");
|
||||||
|
|
||||||
|
await _scheduler.ScheduleJob(job, trigger);
|
||||||
|
_logger.LogDebug("Job scheduled.");
|
||||||
|
|
||||||
|
await _scheduler.Start();
|
||||||
|
_logger.LogDebug("Scheduler started!");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SchedulerFinishEnvelope failed to start");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Stop()
|
||||||
|
{
|
||||||
|
if (_scheduler is null) return;
|
||||||
|
_logger.LogInformation("Stopping SchedulerFinishEnvelope...");
|
||||||
|
await _scheduler.Shutdown();
|
||||||
|
_logger.LogInformation("SchedulerFinishEnvelope stopped!");
|
||||||
|
}
|
||||||
|
}
|
||||||
14
EnvelopeGenerator.Service/ServiceConfig.cs
Normal file
14
EnvelopeGenerator.Service/ServiceConfig.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service-level configuration bound from the "ServiceConfig" section of appsettings.json.
|
||||||
|
/// Connection string is read separately via the standard "ConnectionStrings:Default" key.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceConfig
|
||||||
|
{
|
||||||
|
public bool Debug { get; set; } = false;
|
||||||
|
public int IntervalInMin { get; set; } = 1;
|
||||||
|
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
|
||||||
|
}
|
||||||
75
EnvelopeGenerator.Service/TempFiles.cs
Normal file
75
EnvelopeGenerator.Service/TempFiles.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace EnvelopeGenerator.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service-level temp file management. Mirrors <c>TempFiles.vb</c> from the legacy project.
|
||||||
|
/// Manages <c>%TEMP%\EnvelopeGenerator</c> at host startup and shutdown.
|
||||||
|
///
|
||||||
|
/// NOTE: This is independent from <c>EnvelopeGenerator.CommonServices.TempFiles</c>, which is
|
||||||
|
/// instantiated inside each Quartz job via <see cref="DigitalData.Modules.Logging.LogConfig"/>.
|
||||||
|
/// Do not confuse the two.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TempFiles
|
||||||
|
{
|
||||||
|
private readonly ILogger<TempFiles> _logger;
|
||||||
|
|
||||||
|
public string TempPath { get; }
|
||||||
|
|
||||||
|
public TempFiles(ILogger<TempFiles> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Create()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(TempPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(TempPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CleanUpFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create temp directory: {TempPath}", TempPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanUpFiles()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.GetFiles(TempPath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting temp file: {File}", file);
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clean up temp files in: {TempPath}", TempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CleanUp()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting temp directory: {TempPath}", TempPath);
|
||||||
|
Directory.Delete(TempPath, recursive: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete temp directory: {TempPath}", TempPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
EnvelopeGenerator.Service/Worker.cs
Normal file
111
EnvelopeGenerator.Service/Worker.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using DigitalData.Modules.Database;
|
||||||
|
using EnvelopeGenerator.Service.Schedulers;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Service;
|
||||||
|
|
||||||
|
public sealed class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<Worker> _logger;
|
||||||
|
private readonly ServiceConfig _config;
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly TempFiles _tempFiles;
|
||||||
|
private readonly SchedulerFinishEnvelope _schedulerFinishEnvelope;
|
||||||
|
private readonly SchedulerEnvelopeTaskApi _schedulerEnvelopeTaskApi;
|
||||||
|
|
||||||
|
public Worker(
|
||||||
|
ILogger<Worker> logger,
|
||||||
|
IOptions<ServiceConfig> config,
|
||||||
|
IConfiguration configuration,
|
||||||
|
TempFiles tempFiles,
|
||||||
|
SchedulerFinishEnvelope schedulerFinishEnvelope,
|
||||||
|
SchedulerEnvelopeTaskApi schedulerEnvelopeTaskApi)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_config = config.Value;
|
||||||
|
_connectionString = configuration.GetConnectionString("Default")
|
||||||
|
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
|
||||||
|
_tempFiles = tempFiles;
|
||||||
|
_schedulerFinishEnvelope = schedulerFinishEnvelope;
|
||||||
|
_schedulerEnvelopeTaskApi = schedulerEnvelopeTaskApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("EnvelopeGenerator.Service starting...");
|
||||||
|
|
||||||
|
// 1. Validate connection string
|
||||||
|
if (string.IsNullOrWhiteSpace(_connectionString))
|
||||||
|
throw new InvalidOperationException("Connection string is empty!");
|
||||||
|
|
||||||
|
// 2. Build LogConfig (required by CommonServices jobs via JobDataMap)
|
||||||
|
var logConfig = LogConfigFactory.Create(_config);
|
||||||
|
_logger.LogInformation("DEBUG = {Debug}", _config.Debug);
|
||||||
|
|
||||||
|
// 3. Connect to DB and load GDPicture license
|
||||||
|
_logger.LogDebug("Initializing Database...");
|
||||||
|
var database = new MSSQLServer(logConfig, _connectionString);
|
||||||
|
|
||||||
|
if (!database.DBInitialized)
|
||||||
|
throw new InvalidOperationException("Database connection could not be established!");
|
||||||
|
|
||||||
|
_logger.LogDebug("Database initialized.");
|
||||||
|
|
||||||
|
var licenseKey = database.GetScalarValue(
|
||||||
|
"SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' AND ACTIVE = 1") as string;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(licenseKey))
|
||||||
|
throw new InvalidOperationException("GDPicture license could not be loaded!");
|
||||||
|
|
||||||
|
_logger.LogDebug("GDPicture license loaded.");
|
||||||
|
|
||||||
|
// 4. Create service-level temp directory
|
||||||
|
_tempFiles.Create();
|
||||||
|
|
||||||
|
// 5. Start FinalizeDocument scheduler
|
||||||
|
_logger.LogDebug("Initializing schedulers...");
|
||||||
|
await _schedulerFinishEnvelope.Start(licenseKey!, logConfig);
|
||||||
|
|
||||||
|
// 6. Delay between schedulers (legacy behavior preserved)
|
||||||
|
await Task.Delay(2500, stoppingToken);
|
||||||
|
|
||||||
|
// 7. Start API envelope scheduler
|
||||||
|
await _schedulerEnvelopeTaskApi.Start(logConfig);
|
||||||
|
|
||||||
|
_logger.LogInformation("EnvelopeGenerator.Service started!");
|
||||||
|
|
||||||
|
// 8. Wait for cancellation (service runs until stopped)
|
||||||
|
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Normal shutdown <20> not an error
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "EnvelopeGenerator.Service encountered a fatal error during startup.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("EnvelopeGenerator.Service stopping...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _schedulerFinishEnvelope.Stop();
|
||||||
|
await _schedulerEnvelopeTaskApi.Stop();
|
||||||
|
_tempFiles.CleanUp();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during service shutdown.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
_logger.LogInformation("EnvelopeGenerator.Service stopped!");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
EnvelopeGenerator.Service/appsettings.Development.json
Normal file
8
EnvelopeGenerator.Service/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
EnvelopeGenerator.Service/appsettings.json
Normal file
24
EnvelopeGenerator.Service/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
|
"ServiceConfig": {
|
||||||
|
"Debug": false,
|
||||||
|
"IntervalInMin": 1,
|
||||||
|
"PDFBurnerParams": {
|
||||||
|
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
|
||||||
|
"TopMargin": 0.1,
|
||||||
|
"YOffset": -0.3,
|
||||||
|
"FontName": "Arial",
|
||||||
|
"FontSize": 8,
|
||||||
|
"FontStyle": 2,
|
||||||
|
"TruncationSuffix": "..",
|
||||||
|
"TextMaxWidths": {
|
||||||
|
"position": 1.41,
|
||||||
|
"city": 1.41,
|
||||||
|
"date": 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
EnvelopeGenerator.Service/nlog.config
Normal file
20
EnvelopeGenerator.Service/nlog.config
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
autoReload="true">
|
||||||
|
|
||||||
|
<targets async="true">
|
||||||
|
<target xsi:type="File"
|
||||||
|
name="file"
|
||||||
|
fileName="${basedir}/Log/service-${shortdate}.log"
|
||||||
|
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}${onexception:${newline}${exception:format=tostring}}" />
|
||||||
|
<target xsi:type="Console"
|
||||||
|
name="console"
|
||||||
|
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}" />
|
||||||
|
</targets>
|
||||||
|
|
||||||
|
<rules>
|
||||||
|
<logger name="*" minlevel="Debug" writeTo="file,console" />
|
||||||
|
</rules>
|
||||||
|
|
||||||
|
</nlog>
|
||||||
@@ -23,19 +23,20 @@
|
|||||||
"Annotations": [
|
"Annotations": [
|
||||||
{
|
{
|
||||||
"Name": "Signature",
|
"Name": "Signature",
|
||||||
"MarginTop": 0
|
"MarginTop": 0,
|
||||||
|
"MarginLeft": 0.20
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "PositionLabel",
|
"Name": "PositionLabel",
|
||||||
"VerBoundAnnotName": "Signature",
|
"VerBoundAnnotName": "Signature",
|
||||||
"WidthRatio": 1.2,
|
"WidthRatio": 1.2,
|
||||||
"HeightRatio": 0.5,
|
"HeightRatio": 0.5,
|
||||||
"MarginTopRatio": 0.22
|
"MarginTopRatio": 0.30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Position",
|
"Name": "Position",
|
||||||
"VerBoundAnnotName": "PositionLabel",
|
"VerBoundAnnotName": "PositionLabel",
|
||||||
"WidthRatio": 1.2,
|
"WidthRatio": 1.4,
|
||||||
"HeightRatio": 0.5,
|
"HeightRatio": 0.5,
|
||||||
"MarginTopRatio": -0.05
|
"MarginTopRatio": -0.05
|
||||||
},
|
},
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
{
|
{
|
||||||
"Name": "City",
|
"Name": "City",
|
||||||
"VerBoundAnnotName": "CityLabel",
|
"VerBoundAnnotName": "CityLabel",
|
||||||
"WidthRatio": 1.2,
|
"WidthRatio": 1.4,
|
||||||
"HeightRatio": 0.5,
|
"HeightRatio": 0.5,
|
||||||
"MarginTopRatio": -0.05
|
"MarginTopRatio": -0.05
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ async function createAnnotations(document, envelopeId, receiverId) {
|
|||||||
|
|
||||||
//#region signatures
|
//#region signatures
|
||||||
const id = generateId(envelopeId, receiverId, element.id, 'signature');
|
const id = generateId(envelopeId, receiverId, element.id, 'signature');
|
||||||
|
|
||||||
|
const boundingBox = new PSPDFKit.Geometry.Rect(annotParams.signature);
|
||||||
|
|
||||||
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
|
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
|
||||||
id: id,
|
id: id,
|
||||||
pageIndex: page,
|
pageIndex: page,
|
||||||
formFieldName: id,
|
formFieldName: id,
|
||||||
backgroundColor: PSPDFKit.Color.LIGHT_YELLOW,
|
backgroundColor: null,
|
||||||
blendMode: 'normal',
|
blendMode: 'normal',
|
||||||
boundingBox: new PSPDFKit.Geometry.Rect(annotParams.signature),
|
boundingBox: boundingBox,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formField = new PSPDFKit.FormFields.SignatureFormField({
|
const formField = new PSPDFKit.FormFields.SignatureFormField({
|
||||||
@@ -237,7 +240,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
|
|||||||
Comp.SignatureProgress.SignedCount += 1;
|
Comp.SignatureProgress.SignedCount += 1;
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const scale = 4
|
const scale = 4
|
||||||
const fontSize = 10
|
const fontSize = 8
|
||||||
|
|
||||||
canvas.width = width * scale
|
canvas.width = width * scale
|
||||||
canvas.height = height * scale
|
canvas.height = height * scale
|
||||||
@@ -270,21 +273,17 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
|
|||||||
ctx.moveTo(30 * scale, 50 * scale)
|
ctx.moveTo(30 * scale, 50 * scale)
|
||||||
ctx.lineTo(signatureLength, 50 * scale)
|
ctx.lineTo(signatureLength, 50 * scale)
|
||||||
|
|
||||||
ctx.strokeStyle = 'darkblue'
|
ctx.strokeStyle = 'darkgray'
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
ctx.fillStyle = 'black'
|
ctx.fillStyle = 'black'
|
||||||
ctx.font = `${fontSize * scale}px sans-serif`
|
ctx.font = `${fontSize * scale}px sans-serif`
|
||||||
ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale)
|
ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale)
|
||||||
ctx.fillText(receiverName, 15 * scale, 60 * scale)
|
ctx.fillText(receiverName, 15 * scale, 58.5 * scale)
|
||||||
ctx.fillText(signatureString, 15 * scale, 70 * scale)
|
ctx.fillText(signatureString, 15 * scale, 69 * scale)
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve))
|
||||||
canvas.toBlob((blob) => {
|
return URL.createObjectURL(blob)
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
resolve(url)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//required
|
//required
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ class App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load annotations into PSPDFKit
|
// Load annotations into PSPDFKit
|
||||||
|
this.signatureWidgets = [] // { pageIndex, boundingBox, id }
|
||||||
if (!READ_AND_CONFIRM)
|
if (!READ_AND_CONFIRM)
|
||||||
try {
|
try {
|
||||||
let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId);
|
let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId);
|
||||||
await this.pdfKit.create(signatures);
|
await this.pdfKit.create(signatures);
|
||||||
|
// Cache signature widget bounding boxes for stable frame placement
|
||||||
|
this.signatureWidgets = signatures
|
||||||
|
.filter(s => s instanceof PSPDFKit.Annotations.WidgetAnnotation && s.formFieldName?.includes('signature'))
|
||||||
|
.map(s => ({ id: s.id, pageIndex: s.pageIndex, boundingBox: s.boundingBox.toJS() }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error loading annotations:", e);
|
console.error("Error loading annotations:", e);
|
||||||
}
|
}
|
||||||
@@ -66,8 +71,27 @@ class App {
|
|||||||
|
|
||||||
if (isFormField === false && isSignature === true) {
|
if (isFormField === false && isSignature === true) {
|
||||||
|
|
||||||
const left = annotation.boundingBox.left - 20
|
// Find the nearest signature widget on the same page to use its stable bounding box
|
||||||
const top = annotation.boundingBox.top - 20
|
const inkBBox = annotation.boundingBox
|
||||||
|
const samePageWidgets = this.signatureWidgets.filter(w => w.pageIndex === annotation.pageIndex)
|
||||||
|
const nearestWidget = samePageWidgets.reduce((nearest, w) => {
|
||||||
|
const dx = w.boundingBox.left - inkBBox.left
|
||||||
|
const dy = w.boundingBox.top - inkBBox.top
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
return dist < nearest.dist ? { dist, widget: w } : nearest
|
||||||
|
}, { dist: Infinity, widget: null }).widget
|
||||||
|
|
||||||
|
// 1. Remove widget background after signing
|
||||||
|
if (nearestWidget) {
|
||||||
|
const pageAnnots = await this.pdfKit.getAnnotations(annotation.pageIndex)
|
||||||
|
const widgetAnnot = pageAnnots.find(a => a.id === nearestWidget.id)
|
||||||
|
if (widgetAnnot)
|
||||||
|
await this.pdfKit.update(widgetAnnot.set('backgroundColor', PSPDFKit.Color.TRANSPARENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = nearestWidget?.boundingBox ?? inkBBox
|
||||||
|
const left = bbox.left - 20
|
||||||
|
const top = bbox.top - 12
|
||||||
const width = 150
|
const width = 150
|
||||||
const height = 75
|
const height = 75
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
@@ -330,7 +354,7 @@ class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleReset(event) {
|
async handleReset(event) {
|
||||||
const result = Swal.fire({
|
const result = await Swal.fire({
|
||||||
title: localized.resetConfirmTitle,
|
title: localized.resetConfirmTitle,
|
||||||
text: localized.resetConfirmText,
|
text: localized.resetConfirmText,
|
||||||
icon: 'question',
|
icon: 'question',
|
||||||
@@ -338,7 +362,24 @@ class App {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.isConfirmed) {
|
if (result.isConfirmed) {
|
||||||
const result = await deleteAnnotations(this.pdfKit)
|
await deleteAnnotations(this.pdfKit)
|
||||||
|
|
||||||
|
// Reset editable text form field values (Position, City)
|
||||||
|
const allFormFields = await this.pdfKit.getFormFields()
|
||||||
|
const editableFields = allFormFields.filter(f =>
|
||||||
|
f instanceof PSPDFKit.FormFields.TextFormField && !f.readOnly
|
||||||
|
)
|
||||||
|
for (const field of editableFields) {
|
||||||
|
await this.pdfKit.setFormFieldValues({ [field.name]: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore widget backgrounds that were made transparent after signing
|
||||||
|
for (const w of this.signatureWidgets) {
|
||||||
|
const pageAnnots = await this.pdfKit.getAnnotations(w.pageIndex)
|
||||||
|
const widgetAnnot = pageAnnots.find(a => a.id === w.id)
|
||||||
|
if (widgetAnnot)
|
||||||
|
await this.pdfKit.update(widgetAnnot.set('backgroundColor', null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
4
EnvelopeGenerator.Web/wwwroot/js/app.min.js
vendored
4
EnvelopeGenerator.Web/wwwroot/js/app.min.js
vendored
@@ -1,3 +1,3 @@
|
|||||||
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n)}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const r=t.boundingBox.left-20,u=t.boundingBox.top-20,n=150,i=75,f=new Date,e=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,f,n,i),o=await fetch(e),s=await o.blob(),h=await this.pdfKit.createAttachment(s),c=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:r,top:u,width:n,height:i}),t.pageIndex,h,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(c)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
|
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),this.signatureWidgets=[],!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n);this.signatureWidgets=n.filter(n=>n instanceof PSPDFKit.Annotations.WidgetAnnotation&&n.formFieldName?.includes("signature")).map(n=>({id:n.id,pageIndex:n.pageIndex,boundingBox:n.boundingBox.toJS()}))}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const n=t.boundingBox,e=this.signatureWidgets.filter(n=>n.pageIndex===t.pageIndex),i=e.reduce((t,i)=>{const r=i.boundingBox.left-n.left,u=i.boundingBox.top-n.top,f=Math.sqrt(r*r+u*u);return f<t.dist?{dist:f,widget:i}:t},{dist:Infinity,widget:null}).widget;if(i){const r=await this.pdfKit.getAnnotations(t.pageIndex),n=r.find(n=>n.id===i.id);n&&await this.pdfKit.update(n.set("backgroundColor",PSPDFKit.Color.TRANSPARENT))}const r=i?.boundingBox??n,o=r.left-20,s=r.top-12,u=150,f=75,h=new Date,c=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,h,u,f),l=await fetch(c),a=await l.blob(),v=await this.pdfKit.createAttachment(a),y=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:o,top:s,width:u,height:f}),t.pageIndex,v,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(y)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
|
||||||
Request failed: ${t}
|
Request failed: ${t}
|
||||||
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){const n=await deleteAnnotations(this.pdfKit)}return n}fakeElementId=0;}
|
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=await Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){await deleteAnnotations(this.pdfKit);const n=await this.pdfKit.getFormFields(),t=n.filter(n=>n instanceof PSPDFKit.FormFields.TextFormField&&!n.readOnly);for(const n of t)await this.pdfKit.setFormFieldValues({[n.name]:""});for(const n of this.signatureWidgets){const i=await this.pdfKit.getAnnotations(n.pageIndex),t=i.find(t=>t.id===n.id);t&&await this.pdfKit.update(t.set("backgroundColor",null))}}return n}fakeElementId=0;}
|
||||||
@@ -9,8 +9,6 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.CommonSer
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Web", "EnvelopeGenerator.Web\EnvelopeGenerator.Web.csproj", "{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Web", "EnvelopeGenerator.Web\EnvelopeGenerator.Web.csproj", "{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Domain", "EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj", "{4F32A98D-E6F0-4A09-BD97-1CF26107E837}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Domain", "EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj", "{4F32A98D-E6F0-4A09-BD97-1CF26107E837}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Infrastructure", "EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj", "{63E32615-0ECA-42DC-96E3-91037324B7C7}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Infrastructure", "EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj", "{63E32615-0ECA-42DC-96E3-91037324B7C7}"
|
||||||
@@ -22,6 +20,8 @@ EndProject
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
|
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
|
||||||
|
fix-report-label-read-and-confirmed.md = fix-report-label-read-and-confirmed.md
|
||||||
|
fix-signature-field-formatting.md = fix-signature-field-formatting.md
|
||||||
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
|
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
|
||||||
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
|
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
@@ -45,11 +45,18 @@ EndProject
|
|||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{FB2D306B-1042-4A70-31ED-F991A1599371}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{FB2D306B-1042-4A70-31ED-F991A1599371}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
||||||
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server.Client", "EnvelopeGenerator.Server\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj", "{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server.Client", "EnvelopeGenerator.Server\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj", "{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_legacy", "EnvelopeGenerator.Service_legacy\EnvelopeGenerator.Service_legacy.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -68,10 +75,6 @@ Global
|
|||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
@@ -116,6 +119,14 @@ Global
|
|||||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -124,7 +135,6 @@ Global
|
|||||||
{089D5634-FB6B-42D0-B912-7AA7457044E7} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
{089D5634-FB6B-42D0-B912-7AA7457044E7} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
||||||
{6EA0C51F-C2B1-4462-8198-3DE0B32B74F8} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{6EA0C51F-C2B1-4462-8198-3DE0B32B74F8} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
|
||||||
{4F32A98D-E6F0-4A09-BD97-1CF26107E837} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{4F32A98D-E6F0-4A09-BD97-1CF26107E837} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{63E32615-0ECA-42DC-96E3-91037324B7C7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
{63E32615-0ECA-42DC-96E3-91037324B7C7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
{5A9984F8-51A2-4558-A415-EC5FEED7CF7D} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{5A9984F8-51A2-4558-A415-EC5FEED7CF7D} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
@@ -140,6 +150,8 @@ Global
|
|||||||
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||||
|
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
||||||
|
|||||||
135
fix-report-label-read-and-confirmed.md
Normal file
135
fix-report-label-read-and-confirmed.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Fix: Report Label "Read and confirmed" for Read and Sign Envelopes
|
||||||
|
|
||||||
|
## Status
|
||||||
|
✅ **COMPLETED**
|
||||||
|
|
||||||
|
## Git Branch
|
||||||
|
```
|
||||||
|
fix/report-label-read-and-confirmed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
In the **Signature Certificate report** (`Signierungszertifikat`), the history event
|
||||||
|
list always showed **"Document signed"** for status `DocumentSigned` (code `2005`) <20>
|
||||||
|
even when the envelope type was **"Read and Sign"** (`EnvelopeTypeId = 2`).
|
||||||
|
|
||||||
|
For "Read and Sign" envelopes the correct label must be **"Read and confirmed"**
|
||||||
|
(DE: **"Gelesen und best<73>tigt"**).
|
||||||
|
|
||||||
|
### Screenshot reference
|
||||||
|
The report shows a table with columns *Ereignis | Benutzer | Zeitstempel*.
|
||||||
|
The row with status 2005 displayed "Document signed" where "Read and confirmed"
|
||||||
|
was expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`ReportItem.ItemStatusTranslated` in
|
||||||
|
`EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb`
|
||||||
|
resolved the display label by calling:
|
||||||
|
|
||||||
|
```vb
|
||||||
|
My.Resources.Model.ResourceManager.GetString(ItemStatus.ToString())
|
||||||
|
```
|
||||||
|
|
||||||
|
There was **no branching logic** to handle the Read and Sign envelope type.
|
||||||
|
The same `DocumentSigned` key was always used regardless of envelope type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Convention followed
|
||||||
|
The project already uses a `RaC` (**R**ead **a**nd **C**onfirm) suffix for
|
||||||
|
Read-and-Sign label variants across all resource files:
|
||||||
|
|
||||||
|
| Existing RaC keys |
|
||||||
|
|---|
|
||||||
|
| `DocumentRejectedRaC` |
|
||||||
|
| `EnvelopeReportCreatedRaC` |
|
||||||
|
| `MessageConfirmationSentRaC` |
|
||||||
|
|
||||||
|
### New resource key added: `DocumentSignedRaC`
|
||||||
|
|
||||||
|
| Resource file | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Model.resx` (DE, default) | `Gelesen und best<73>tigt` |
|
||||||
|
| `Model.en.resx` (EN) | `Read and confirmed` |
|
||||||
|
|
||||||
|
### Logic change in `ReportItem.vb`
|
||||||
|
|
||||||
|
```vb
|
||||||
|
Public ReadOnly Property ItemStatusTranslated As String
|
||||||
|
Get
|
||||||
|
Dim oStatusKey = ItemStatus.ToString()
|
||||||
|
|
||||||
|
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
|
||||||
|
Envelope IsNot Nothing AndAlso
|
||||||
|
Envelope.IsReadAndConfirm() Then
|
||||||
|
oStatusKey = "DocumentSignedRaC"
|
||||||
|
End If
|
||||||
|
|
||||||
|
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
|
||||||
|
End Get
|
||||||
|
End Property
|
||||||
|
```
|
||||||
|
|
||||||
|
`Envelope.IsReadAndConfirm()` is defined in
|
||||||
|
`EnvelopeGenerator.Domain\Interfaces\IEnvelope.cs` and returns `true`
|
||||||
|
when `EnvelopeTypeId == 2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| # | File | Change | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb` | Added `RaC` branching logic to `ItemStatusTranslated` | ? Done |
|
||||||
|
| 2 | `EnvelopeGenerator.Domain\Resources\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
|
||||||
|
| 3 | `EnvelopeGenerator.CommonServices\Strings\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
|
||||||
|
| 4 | `EnvelopeGenerator.CommonServices\Strings\Model.en.resx` | Added `DocumentSignedRaC` = `Read and confirmed` | ✅ Done |
|
||||||
|
| 5 | `EnvelopeGenerator.Domain\Resources\Model.Designer.cs` | Added `DocumentSignedRaC` strongly-typed property | ✅ Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to resume (for next agent)
|
||||||
|
|
||||||
|
All pending items are **resource file additions only**.
|
||||||
|
Each file needs one new `<data>` block inserted **directly after** the existing
|
||||||
|
`DocumentSigned` entry:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<data name="DocumentSignedRaC" xml:space="preserve">
|
||||||
|
<value>Gelesen und best<73>tigt</value> <!-- DE files -->
|
||||||
|
<!-- OR -->
|
||||||
|
<value>Read and confirmed</value> <!-- EN file -->
|
||||||
|
</data>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Model.Designer.cs` also needs a new strongly-typed property added after
|
||||||
|
`DocumentSigned`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Gelesen und best<73>tigt.
|
||||||
|
/// </summary>
|
||||||
|
public static string DocumentSignedRaC {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After changes, generate a Signature Certificate report for a **Read and Sign**
|
||||||
|
envelope and verify:
|
||||||
|
- History row with status 2005 shows **"Read and confirmed"** (EN)
|
||||||
|
- History row with status 2005 shows **"Gelesen und best<73>tigt"** (DE)
|
||||||
|
- Regular (non-RaC) envelopes still show **"Document signed"** / **"Dokument unterzeichnet"**
|
||||||
111
fix-signature-field-formatting.md
Normal file
111
fix-signature-field-formatting.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Signature Field Formatting — WISAG Feedback
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Customer feedback received from the new WISAG signFLOW project lead (email dated 3/7/2026).
|
||||||
|
The signature stamp rendered inside the finalized PDF has layout issues that need to be addressed.
|
||||||
|
|
||||||
|
### Current State (screenshot from customer email)
|
||||||
|
|
||||||
|
```
|
||||||
|
Signiert von ___________
|
||||||
|
[ signature image ]
|
||||||
|
Armin Schneider
|
||||||
|
C6B85E96A520C60... ← certificate hash, too close to name
|
||||||
|
Bereichsleiter Kern- ← position text wraps, no spacing to date
|
||||||
|
Anwendungen
|
||||||
|
Frankfurt am Main
|
||||||
|
23.06.2026, 17:13:42 GMT+2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issues Reported
|
||||||
|
|
||||||
|
1. **Spacing between name and certificate number** is too small — needs more vertical gap.
|
||||||
|
2. **Spacing between position/title and date/time** is too small — should be dynamic (expand with content).
|
||||||
|
3. *(Secondary, out of scope here)* Multiple signature fields side-by-side scale individually, causing uneven heights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agreed Solution
|
||||||
|
|
||||||
|
> **"Height stays fixed. Text stretches horizontally. Beyond a certain character limit, truncate with '...'"**
|
||||||
|
> *(Original: "Höhe bleibt fix. Text dehnt sich horizontal aus. Ab einem bestimmten Limit wird mit '...' abgekürzt.")*
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- The signature field dimensions are **fixed** (`1.77" × 1.96"`). Allowing text to wrap causes position and date rows to collide vertically.
|
||||||
|
- Truncating long strings with ellipsis (`...`) keeps the layout deterministic and prevents overflow.
|
||||||
|
- Per-field max-length limits allow tuning each text row independently (name, certificate, position, city, date).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### 1. `PDFBurnerParams.vb` — Add per-field max-length dictionary
|
||||||
|
|
||||||
|
Add a new property `TextMaxLengths` that maps each field name (matching `EGName` keys) to a maximum character count.
|
||||||
|
|
||||||
|
```vbnet
|
||||||
|
' EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb
|
||||||
|
|
||||||
|
Public Property TextMaxLengths As New Dictionary(Of String, Integer) From {
|
||||||
|
{"name", 28},
|
||||||
|
{"position", 32},
|
||||||
|
{"city", 24},
|
||||||
|
{"date", 28}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default values are starting points and should be tuned against real customer data.
|
||||||
|
|
||||||
|
### 2. `PDFBurner.vb` — Add truncation helper and apply in `AddFormFieldValue`
|
||||||
|
|
||||||
|
#### 2a. Add a private helper function
|
||||||
|
|
||||||
|
```vbnet
|
||||||
|
Private Function TruncateText(value As String, fieldName As String) As String
|
||||||
|
If String.IsNullOrEmpty(value) Then Return value
|
||||||
|
|
||||||
|
Dim maxLen As Integer = 0
|
||||||
|
If _pdfBurnerParams.TextMaxLengths IsNot Nothing AndAlso
|
||||||
|
_pdfBurnerParams.TextMaxLengths.TryGetValue(fieldName, maxLen) AndAlso
|
||||||
|
maxLen > 0 AndAlso value.Length > maxLen Then
|
||||||
|
Return value.Substring(0, maxLen - 3) & "..."
|
||||||
|
End If
|
||||||
|
|
||||||
|
Return value
|
||||||
|
End Function
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2b. Apply truncation in the `egName`-aware overload
|
||||||
|
|
||||||
|
In `AddFormFieldValue(pAnnotation As Annotation, formFieldValue As FormFieldValue)`:
|
||||||
|
|
||||||
|
```vbnet
|
||||||
|
' Before passing value to AddTextAnnot, truncate:
|
||||||
|
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
|
||||||
|
|
||||||
|
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2c. Apply truncation in the element-based overload (optional)
|
||||||
|
|
||||||
|
In `AddFormFieldValue(x, y, width, height, page, value)` — if a field name is available in calling context, pass it through; otherwise this path does not use `egName` and truncation can be skipped or applied with a generic fallback key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb` | Add `TextMaxLengths As Dictionary(Of String, Integer)` |
|
||||||
|
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurner.vb` | Add `TruncateText()` helper; apply in `AddFormFieldValue` overloads |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Field names (`"name"`, `"position"`, `"city"`, `"date"`) must match the keys used in `EGName.Index` dictionary (`PDFBurner.vb:443–450`).
|
||||||
|
- The certificate hash field (e.g. `C6B85E96A520C60...`) is already truncated upstream — verify its `egName` mapping if it needs its own limit.
|
||||||
|
- The `YOffset` / `TopMargin` values in `PDFBurnerParams` may also need adjustment to increase vertical spacing between rows. That is a separate (simpler) change.
|
||||||
|
- This issue is **not** related to the Blazor WebUI rendering — it is purely in the GdPicture14 PDF burning pipeline.
|
||||||
Reference in New Issue
Block a user