From 613cc10acbb3cb1863b5df48c264042c37194b58 Mon Sep 17 00:00:00 2001 From: MNSOFT <137189378+devmnsoft@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:22:40 -0300 Subject: [PATCH] Fix contract rendering compilation errors --- .../Controllers/ContractStudioController.cs | 18 +++---- .../Contracts/ContractDocumentRenderer.cs | 47 +++++++++++++------ .../StructuredContractDocumentTests.cs | 47 +++++++++++++++++++ 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/Odca.Api/Controllers/ContractStudioController.cs b/src/Odca.Api/Controllers/ContractStudioController.cs index b5d9ab9..c47fb63 100644 --- a/src/Odca.Api/Controllers/ContractStudioController.cs +++ b/src/Odca.Api/Controllers/ContractStudioController.cs @@ -240,7 +240,7 @@ LEFT JOIN LATERAL (SELECT r.id FROM odca.contract_review_requests r if(preparation is not null)preparation=preparation with{Readiness=await CalculateReadiness(c,tenantId,versionId,tx,ct)}; string html; try { html=ContractDocumentRenderer.ToHtml(row.Content,row.Fields,row.Values); } - catch(InvalidDataException exception) { return Problem(statusCode:422,title="A estrutura histórica não pode ser apresentada.",detail=exception.Message); } + catch(InvalidDataException exception) { return Problem(statusCode:StatusCodes.Status422UnprocessableEntity,title:"A estrutura histórica não pode ser apresentada.",detail:exception.Message); } await tx.CommitAsync(ct); return Ok(new GeneratedVersionDetail(row.Id,row.ContractId,row.DraftId,row.Number,row.Title,row.DocumentType, row.Organization,row.Template,row.TemplateVersion,row.Author,row.CreatedAt,row.Sha256,row.ReviewStatus, row.SignatureStatus,row.ReviewId,ParseOptional(row.PatientSnapshot),JsonSerializer.Deserialize(row.Content), @@ -255,12 +255,12 @@ public async Task GeneratePdf(Guid tenantId,Guid versionId,Cancel await using var tx=await c.BeginTransactionAsync(ct);await SetTenant(c,tenantId,actor.Value,tx,ct); var row=await c.QuerySingleOrDefaultAsync(new CommandDefinition("SELECT id AS Id,coalesce(emission_metadata->>'title','Documento') AS Title,version_number AS Number,content::text AS Content,fields::text AS Fields,values::text AS Values,pdf_status AS Status,pdf_storage_key AS StorageKey,pdf_byte_size AS ByteSize FROM odca.generated_contract_versions WHERE tenant_id=@tenantId AND id=@versionId FOR UPDATE",new{tenantId,versionId},tx,cancellationToken:ct)); if(row is null)return NotFound();if(row.Status=="completed"){await tx.CommitAsync(ct);return Ok(new{status="completed",byteSize=row.ByteSize,replayed=true});} - byte[] pdf;try{pdf=ContractDocumentRenderer.ToPdf(row.Content,row.Fields,row.Values,row.Title,row.Number);}catch(InvalidDataException e){await c.ExecuteAsync(new CommandDefinition("UPDATE odca.generated_contract_versions SET pdf_status='failed',pdf_failure_code='invalid_structure' WHERE tenant_id=@tenantId AND id=@versionId",new{tenantId,versionId},tx,cancellationToken:ct));await tx.CommitAsync(ct);return Problem(statusCode:422,title="Não foi possível gerar o PDF.",detail=e.Message);} + byte[] pdf;try{pdf=ContractDocumentRenderer.ToPdf(row.Content,row.Fields,row.Values,row.Title,row.Number);}catch(InvalidDataException e){await c.ExecuteAsync(new CommandDefinition("UPDATE odca.generated_contract_versions SET pdf_status='failed',pdf_failure_code='invalid_structure' WHERE tenant_id=@tenantId AND id=@versionId",new{tenantId,versionId},tx,cancellationToken:ct));await tx.CommitAsync(ct);return Problem(statusCode:StatusCodes.Status422UnprocessableEntity,title:"Não foi possível gerar o PDF.",detail:e.Message);} var hash=Convert.ToHexString(SHA256.HashData(pdf)).ToLowerInvariant();var key=$"generated/{tenantId:N}/{versionId:N}/final-{ContractDocumentRenderer.PdfRendererVersion}-{hash[..16]}.pdf";var root=configuration["Documents:StoragePath"]??Path.Combine(AppContext.BaseDirectory,"App_Data","documents");var path=Path.Combine(root,key.Replace('/',Path.DirectorySeparatorChar));var temporary=path+".attempt-"+Guid.NewGuid().ToString("N");Directory.CreateDirectory(Path.GetDirectoryName(path)!); try { await System.IO.File.WriteAllBytesAsync(temporary,pdf,ct); - if(System.IO.File.Exists(path)){var existing=await System.IO.File.ReadAllBytesAsync(path,ct);if(!CryptographicOperations.FixedTimeEquals(SHA256.HashData(existing),SHA256.HashData(pdf)))return Problem(statusCode:409,title="Já existe um artefato divergente para esta tentativa.");System.IO.File.Delete(temporary);}else System.IO.File.Move(temporary,path,false); + if(System.IO.File.Exists(path)){var existing=await System.IO.File.ReadAllBytesAsync(path,ct);if(!CryptographicOperations.FixedTimeEquals(SHA256.HashData(existing),SHA256.HashData(pdf)))return Problem(statusCode:StatusCodes.Status409Conflict,title:"Já existe um artefato divergente para esta tentativa.");System.IO.File.Delete(temporary);}else System.IO.File.Move(temporary,path,false); var reserved=await c.ExecuteScalarAsync(new CommandDefinition(""" INSERT INTO odca.tenant_storage_usage(tenant_id) VALUES(@tenantId) ON CONFLICT DO NOTHING; UPDATE odca.tenant_storage_usage u SET quota_bytes=q.effective_quota @@ -271,7 +271,7 @@ WHERE EXISTS(SELECT 1 FROM odca.tenant_storage_usage WHERE tenant_id=@tenantId A ON CONFLICT(tenant_id,idempotency_key) DO NOTHING RETURNING 1) UPDATE odca.tenant_storage_usage SET used_bytes=used_bytes+@size WHERE tenant_id=@tenantId AND EXISTS(SELECT 1 FROM movement) RETURNING true """,new{tenantId,versionId,size=pdf.LongLength,actor},tx,cancellationToken:ct)); - if(!reserved)return Problem(statusCode:413,title="A cota efetiva de armazenamento da organização foi atingida."); + if(!reserved)return Problem(statusCode:StatusCodes.Status413PayloadTooLarge,title:"A cota efetiva de armazenamento da organização foi atingida."); await c.ExecuteAsync(new CommandDefinition("UPDATE odca.generated_contract_versions SET pdf_status='completed',pdf_storage_key=@key,pdf_sha256=@hash,pdf_byte_size=@size,pdf_renderer_version=@renderer,pdf_completed_at=now(),pdf_failure_code=NULL WHERE tenant_id=@tenantId AND id=@versionId",new{tenantId,versionId,key,hash,size=pdf.LongLength,renderer=ContractDocumentRenderer.PdfRendererVersion},tx,cancellationToken:ct));await tx.CommitAsync(ct);return Ok(new{status="completed",byteSize=pdf.LongLength,replayed=false}); } finally{if(System.IO.File.Exists(temporary))System.IO.File.Delete(temporary);} @@ -281,7 +281,7 @@ ON CONFLICT(tenant_id,idempotency_key) DO NOTHING RETURNING 1) public async Task DownloadPdf(Guid tenantId,Guid versionId,CancellationToken ct) { var actor=Actor();if(actor is null)return Unauthorized();await using var c=await dataSource.OpenConnectionAsync(ct);var canReadPatientDocuments=await Allowed(c,actor.Value,tenantId,"tenant.patients.documents.read",ct);var canReadDrafts=await Allowed(c,actor.Value,tenantId,"tenant.contract_drafts.read",ct);if(!canReadPatientDocuments&&!canReadDrafts)return Forbid();await using var tx=await c.BeginTransactionAsync(ct);await SetTenant(c,tenantId,actor.Value,tx,ct); - var row=await c.QuerySingleOrDefaultAsync(new CommandDefinition("SELECT pdf_storage_key AS StorageKey,pdf_sha256 AS Sha256,coalesce(emission_metadata->>'title','documento') AS Title,patient_id AS PatientId FROM odca.generated_contract_versions WHERE tenant_id=@tenantId AND id=@versionId AND pdf_status='completed'",new{tenantId,versionId},tx,cancellationToken:ct));if(row is null)return NotFound();if(!canReadDrafts&&row.PatientId is null)return Forbid();var root=configuration["Documents:StoragePath"]??Path.Combine(AppContext.BaseDirectory,"App_Data","documents");var path=Path.Combine(root,row.StorageKey.Replace('/',Path.DirectorySeparatorChar));if(!System.IO.File.Exists(path))return Problem(statusCode:410,title="O arquivo final não está disponível.");var bytes=await System.IO.File.ReadAllBytesAsync(path,ct);if(!CryptographicOperations.FixedTimeEquals(SHA256.HashData(bytes),Convert.FromHexString(row.Sha256)))return Problem(statusCode:409,title="A integridade do PDF não pôde ser confirmada.");await tx.CommitAsync(ct);return File(bytes,"application/pdf",$"{SafeFileName(row.Title)}.pdf",false); + var row=await c.QuerySingleOrDefaultAsync(new CommandDefinition("SELECT pdf_storage_key AS StorageKey,pdf_sha256 AS Sha256,coalesce(emission_metadata->>'title','documento') AS Title,patient_id AS PatientId FROM odca.generated_contract_versions WHERE tenant_id=@tenantId AND id=@versionId AND pdf_status='completed'",new{tenantId,versionId},tx,cancellationToken:ct));if(row is null)return NotFound();if(!canReadDrafts&&row.PatientId is null)return Forbid();var root=configuration["Documents:StoragePath"]??Path.Combine(AppContext.BaseDirectory,"App_Data","documents");var path=Path.Combine(root,row.StorageKey.Replace('/',Path.DirectorySeparatorChar));if(!System.IO.File.Exists(path))return Problem(statusCode:StatusCodes.Status410Gone,title:"O arquivo final não está disponível.");var bytes=await System.IO.File.ReadAllBytesAsync(path,ct);if(!CryptographicOperations.FixedTimeEquals(SHA256.HashData(bytes),Convert.FromHexString(row.Sha256)))return Problem(statusCode:StatusCodes.Status409Conflict,title:"A integridade do PDF não pôde ser confirmada.");await tx.CommitAsync(ct);return File(bytes,"application/pdf",$"{SafeFileName(row.Title)}.pdf",false); } [HttpGet("versions/{versionId:guid}/signature-preparation/readiness")] @@ -459,7 +459,7 @@ public async Task Comments(Guid tenantId, Guid draftId, [FromQuer { var actor = Actor(); if (actor is null) return Unauthorized(); await using var c = await dataSource.OpenConnectionAsync(ct); if (!await Allowed(c, actor.Value, tenantId, "tenant.contract_drafts.read", ct)) return Forbid(); await using var tx = await c.BeginTransactionAsync(ct); await SetTenant(c, tenantId, actor.Value, tx, ct); - var rows = await c.QueryAsync(new CommandDefinition(""" + var rows = (await c.QueryAsync(new CommandDefinition(""" SELECT m.id AS Id,m.generated_version_id AS VersionId,m.draft_revision AS DraftRevision,m.reference AS Reference,m.body AS Body, m.author_id AS AuthorId,u.display_name AS Author,m.parent_id AS ParentId,m.created_at AS CreatedAt, (m.resolved_at IS NOT NULL) AS Resolved,m.resolved_at AS ResolvedAt,m.reference_located AS ReferenceLocated, @@ -470,8 +470,8 @@ public async Task Comments(Guid tenantId, Guid draftId, [FromQuer LEFT JOIN LATERAL (SELECT occurred_at FROM odca.studio_comment_events e WHERE e.tenant_id=m.tenant_id AND e.comment_id=m.id ORDER BY e.id DESC LIMIT 1) last_event ON true WHERE m.tenant_id=@tenantId AND m.draft_id=@draftId AND m.deleted_at IS NULL AND (@includeResolved OR m.resolved_at IS NULL) ORDER BY coalesce(last_event.occurred_at,m.created_at) DESC,m.id - """, new { tenantId, draftId, includeResolved }, tx, cancellationToken: ct)); - await tx.CommitAsync(ct); return Ok(rows.AsList()); + """, new { tenantId, draftId, includeResolved }, tx, cancellationToken: ct))).AsList(); + await tx.CommitAsync(ct); return Ok(rows); } [HttpPost("drafts/{draftId:guid}/comments")] @@ -605,7 +605,7 @@ private sealed record GeneratedExistingRow(Guid Id,Guid DraftId,int Number,strin private sealed record ComparisonRow(Guid Id,Guid ContractId,int Number,string Author,DateTimeOffset CreatedAt,string Status,string Content,string Fields,string Values); private sealed record CommentStateRow(bool Resolved,string Reference,long DraftRevision,Guid ContractId); private static StudioVersionItem ToItem(ComparisonRow row)=>new(row.Id,row.Number,row.Author,row.CreatedAt,row.Status); - private static IReadOnlyList PatientChanges(string? before, string? after) + private static PatientDataChange[] PatientChanges(string? before, string? after) { if (before is null || after is null) return []; using var left = JsonDocument.Parse(before); using var right = JsonDocument.Parse(after); diff --git a/src/Odca.Application/Contracts/ContractDocumentRenderer.cs b/src/Odca.Application/Contracts/ContractDocumentRenderer.cs index 9be3ec1..7b9456c 100644 --- a/src/Odca.Application/Contracts/ContractDocumentRenderer.cs +++ b/src/Odca.Application/Contracts/ContractDocumentRenderer.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Text; using System.Text.Json; @@ -57,7 +58,7 @@ private static void RenderHtml(JsonElement node, IReadOnlyDictionary'); - if (node.TryGetProperty("content", out var children)) foreach (var child in children) + if (TryGetChildren(node, out var children)) foreach (var child in children) { if (child.GetProperty("type").GetString() == "text" && child.TryGetProperty("marks", out var marks)) { @@ -86,7 +87,7 @@ private static void Flatten(JsonElement node, IReadOnlyDictionary values, StringBuilder line) @@ -94,43 +95,59 @@ private static void CollectText(JsonElement node, IReadOnlyDictionary Values); private static class SimplePdf { public static byte[] Create(IEnumerable source) { - var pages = new List> { [] }; + var pages = new List> + { + new List() + }; foreach (var raw in source) { - if (raw == "\f") { if (pages[^1].Count > 0) pages.Add([]); continue; } - foreach (var line in Wrap(raw, 92)) { if (pages[^1].Count >= 52) pages.Add([]); pages[^1].Add(line); } + if (raw == "\f") { if (pages[^1].Count > 0) pages.Add(new List()); continue; } + foreach (var line in Wrap(raw, 92)) { if (pages[^1].Count >= 52) pages.Add(new List()); pages[^1].Add(line); } } var objects = new List(); objects.Add(Ascii("<< /Type /Catalog /Pages 2 0 R >>")); var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 2).ToArray(); - objects.Add(Ascii($"<< /Type /Pages /Kids [{string.Join(' ', pageIds.Select(x => $"{x} 0 R"))}] /Count {pages.Count} >>")); + objects.Add(Ascii(FormattableString.Invariant($"<< /Type /Pages /Kids [{string.Join(' ', pageIds.Select(x => FormattableString.Invariant($"{x} 0 R")))}] /Count {pages.Count} >>"))); objects.Add(Ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")); for (var p = 0; p < pages.Count; p++) { var content = new StringBuilder("BT /F1 10 Tf 54 790 Td 14 TL "); foreach (var line in pages[p]) content.Append('(').Append(Escape(line)).Append(") Tj T* "); - content.Append($"ET BT /F1 9 Tf 285 28 Td (Página {p + 1} de {pages.Count}) Tj ET"); + content.Append(FormattableString.Invariant($"ET BT /F1 9 Tf 285 28 Td (Página {p + 1} de {pages.Count}) Tj ET")); var bytes = Encoding.Latin1.GetBytes(content.ToString()); - objects.Add(Ascii($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents {pageIds[p] + 1} 0 R >>")); - objects.Add(Concat(Ascii($"<< /Length {bytes.Length} >>\nstream\n"), bytes, Ascii("\nendstream"))); + objects.Add(Ascii(FormattableString.Invariant($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents {pageIds[p] + 1} 0 R >>"))); + objects.Add(Concat(Ascii(FormattableString.Invariant($"<< /Length {bytes.Length} >>\nstream\n")), bytes, Ascii("\nendstream"))); } using var output = new MemoryStream(); output.Write(Ascii("%PDF-1.4\n%âãÏÓ\n")); var offsets = new List { 0 }; - for (var i = 0; i < objects.Count; i++) { offsets.Add(output.Position); output.Write(Ascii($"{i + 1} 0 obj\n")); output.Write(objects[i]); output.Write(Ascii("\nendobj\n")); } - var xref = output.Position; output.Write(Ascii($"xref\n0 {objects.Count + 1}\n0000000000 65535 f \n")); - foreach (var offset in offsets.Skip(1)) output.Write(Ascii($"{offset:0000000000} 00000 n \n")); - output.Write(Ascii($"trailer << /Size {objects.Count + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF")); return output.ToArray(); + for (var i = 0; i < objects.Count; i++) { offsets.Add(output.Position); output.Write(Ascii(FormattableString.Invariant($"{i + 1} 0 obj\n"))); output.Write(objects[i]); output.Write(Ascii("\nendobj\n")); } + var xref = output.Position; output.Write(Ascii(FormattableString.Invariant($"xref\n0 {objects.Count + 1}\n0000000000 65535 f \n"))); + foreach (var offset in offsets.Skip(1)) output.Write(Ascii(offset.ToString("0000000000", CultureInfo.InvariantCulture) + " 00000 n \n")); + output.Write(Ascii(FormattableString.Invariant($"trailer << /Size {objects.Count + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF"))); return output.ToArray(); } - private static IEnumerable Wrap(string text, int width) { if (text.Length == 0) return [""]; var words=text.Split(' '); var result=new List(); var line=""; foreach(var word in words){if(line.Length>0&&line.Length+word.Length+1>width){result.Add(line);line=word;}else line+=line.Length==0?word:" "+word;} result.Add(line);return result; } + private static List Wrap(string text, int width) { if (text.Length == 0) return new List { string.Empty }; var words=text.Split(' '); var result=new List(); var line=""; foreach(var word in words){if(line.Length>0&&line.Length+word.Length+1>width){result.Add(line);line=word;}else line+=line.Length==0?word:" "+word;} result.Add(line);return result; } private static string Escape(string text) => text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("(", "\\(", StringComparison.Ordinal).Replace(")", "\\)", StringComparison.Ordinal).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); private static byte[] Ascii(string value) => Encoding.Latin1.GetBytes(value); private static byte[] Concat(params byte[][] arrays) { var result=new byte[arrays.Sum(x=>x.Length)];var offset=0;foreach(var array in arrays){Buffer.BlockCopy(array,0,result,offset,array.Length);offset+=array.Length;}return result; } diff --git a/tests/Odca.Domain.Tests/StructuredContractDocumentTests.cs b/tests/Odca.Domain.Tests/StructuredContractDocumentTests.cs index bf3ed8c..a776648 100644 --- a/tests/Odca.Domain.Tests/StructuredContractDocumentTests.cs +++ b/tests/Odca.Domain.Tests/StructuredContractDocumentTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text; using Odca.Application.Contracts; namespace Odca.Domain.Tests; @@ -122,4 +124,49 @@ public void RendererShowsOptionalEmptyFieldsWithoutInventingData() Assert.Contains("Não informado",html); } + + [Fact] + public void RendererRejectsContentThatIsNotAnArray() + { + const string malformed = """{"type":"document","content":{"type":"paragraph"}}"""; + + Assert.Throws(() => ContractDocumentRenderer.ToHtml(malformed, "[]", "[]")); + } + + [Fact] + public void PdfSerializationIsCultureIndependentAndCrossReferencesPointToObjects() + { + const string content = """{"type":"document","content":[{"type":"paragraph","content":[{"type":"text","text":"Conteúdo"}]}]}"""; + var originalCulture = CultureInfo.CurrentCulture; + byte[] ptBr; + byte[] enUs; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("pt-BR"); + ptBr = ContractDocumentRenderer.ToPdf(content, "[]", "[]", "Documento", 1); + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + enUs = ContractDocumentRenderer.ToPdf(content, "[]", "[]", "Documento", 1); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + + Assert.Equal(ptBr, enUs); + var pdf = Encoding.Latin1.GetString(ptBr); + var startXrefMarker = "startxref\n"; + var startXref = int.Parse( + pdf.AsSpan(pdf.LastIndexOf(startXrefMarker, StringComparison.Ordinal) + startXrefMarker.Length) + .Slice(0, pdf.AsSpan(pdf.LastIndexOf(startXrefMarker, StringComparison.Ordinal) + startXrefMarker.Length).IndexOf('\n')), + CultureInfo.InvariantCulture); + Assert.StartsWith("xref\n", pdf[startXref..]); + + var xrefLines = pdf[startXref..].Split('\n'); + var objectCount = int.Parse(xrefLines[1].Split(' ')[1], CultureInfo.InvariantCulture) - 1; + for (var i = 0; i < objectCount; i++) + { + var offset = int.Parse(xrefLines[i + 3].AsSpan(0, 10), CultureInfo.InvariantCulture); + Assert.StartsWith($"{i + 1} 0 obj\n", pdf[offset..]); + } + } }