WebApi + ClientApp, GraphQL, Reflection

This commit is contained in:
Boris Milašinović
2026-05-06 20:55:05 +02:00
parent 8f7c704a90
commit 4fb3de19f6
196 changed files with 10395 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
using System.Text;
using PdfSharpCore;
using PdfSharpCore.Drawing;
using PdfSharpCore.Fonts;
using PdfSharpCore.Pdf;
using PdfSharpCore.Utils;
namespace Events.FilesAPI.Features.Certificates;
internal static class CertificatePdfDocumentWriter
{
private const string FontFamilyName = "Arial";
private static int initialized;
public static byte[] CreateCertificate(CertificatePdfModel model)
{
EnsureFontsConfigured();
using var document = new PdfDocument();
PdfPage page = document.AddPage();
page.Size = PageSize.A4;
using XGraphics graphics = XGraphics.FromPdfPage(page);
var titleFont = new XFont(FontFamilyName, 20, XFontStyle.Bold);
var headingFont = new XFont(FontFamilyName, 13, XFontStyle.Bold);
var textFont = new XFont(FontFamilyName, 12, XFontStyle.Regular);
double marginLeft = 50;
double y = 60;
double contentWidth = page.Width - marginLeft * 2;
graphics.DrawString(model.Title, titleFont, XBrushes.DarkBlue, new XRect(marginLeft, y, contentWidth, 30), XStringFormats.TopLeft);
y += 52;
foreach (string paragraph in BuildParagraphs(model))
{
DrawParagraph(graphics, paragraph, textFont, marginLeft, ref y, contentWidth);
y += 8;
}
graphics.DrawString("Sports", headingFont, XBrushes.Black, new XRect(marginLeft, y, contentWidth, 20), XStringFormats.TopLeft);
y += 26;
foreach (string sportName in model.SportNames)
{
DrawParagraph(graphics, $"- {sportName}", textFont, marginLeft + 12, ref y, contentWidth - 12);
y += 4;
}
y += 12;
DrawParagraph(graphics, $"Event ID: {model.EventId}", textFont, marginLeft, ref y, contentWidth);
y += 4;
DrawParagraph(graphics, $"Person ID: {model.PersonId}", textFont, marginLeft, ref y, contentWidth);
using var stream = new MemoryStream();
document.Save(stream, false);
return stream.ToArray();
}
private static void EnsureFontsConfigured()
{
if (Interlocked.Exchange(ref initialized, 1) == 1)
return;
GlobalFontSettings.FontResolver = new FontResolver();
}
private static IEnumerable<string> BuildParagraphs(CertificatePdfModel model)
{
yield return $"This confirms that {model.PersonFullName} participated in the event \"{model.EventName}\".";
if (!string.IsNullOrWhiteSpace(model.PersonFullNameTranscription) &&
!string.Equals(model.PersonFullName, model.PersonFullNameTranscription, StringComparison.OrdinalIgnoreCase))
{
yield return $"Transcribed full name: {model.PersonFullNameTranscription}.";
}
yield return $"Event date: {model.EventDate:dd.MM.yyyy}.";
yield return "The person competed in the following sports:";
}
private static void DrawParagraph(XGraphics graphics, string text, XFont font, double left, ref double y, double width)
{
foreach (string line in WrapText(graphics, text, font, width))
{
graphics.DrawString(line, font, XBrushes.Black, new XRect(left, y, width, 18), XStringFormats.TopLeft);
y += 18;
}
}
private static IEnumerable<string> WrapText(XGraphics graphics, string text, XFont font, double width)
{
if (string.IsNullOrWhiteSpace(text))
{
yield return string.Empty;
yield break;
}
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var lineBuilder = new StringBuilder();
foreach (string word in words)
{
string candidate = lineBuilder.Length == 0 ? word : $"{lineBuilder} {word}";
if (graphics.MeasureString(candidate, font).Width <= width)
{
lineBuilder.Clear();
lineBuilder.Append(candidate);
continue;
}
if (lineBuilder.Length > 0)
{
yield return lineBuilder.ToString();
lineBuilder.Clear();
lineBuilder.Append(word);
}
else
{
yield return word;
}
}
if (lineBuilder.Length > 0)
yield return lineBuilder.ToString();
}
}

View File

@@ -0,0 +1,13 @@
namespace Events.FilesAPI.Features.Certificates;
internal sealed class CertificatePdfModel
{
public string Title { get; init; } = string.Empty;
public string PersonFullName { get; init; } = string.Empty;
public string PersonFullNameTranscription { get; init; } = string.Empty;
public string EventName { get; init; } = string.Empty;
public DateOnly EventDate { get; init; }
public int EventId { get; init; }
public int PersonId { get; init; }
public IReadOnlyList<string> SportNames { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,48 @@
using Events.WebAPI.Contract.Messages;
using Events.FilesAPI.Features.Certificates.Synchronize;
using MassTransit;
using MediatR;
namespace Events.FilesAPI.Features.Certificates;
public class CertificateRegistrationEventsConsumer :
IConsumer<RegistrationCreated>,
IConsumer<RegistrationUpdated>,
IConsumer<RegistrationDeleted>
{
private readonly IMediator mediator;
public CertificateRegistrationEventsConsumer(IMediator mediator)
{
this.mediator = mediator;
}
public Task Consume(ConsumeContext<RegistrationCreated> context)
{
return mediator.Send(new SynchronizeCertificateCommand(
context.Message.EventId,
context.Message.PersonId), context.CancellationToken);
}
public async Task Consume(ConsumeContext<RegistrationUpdated> context)
{
await mediator.Send(new SynchronizeCertificateCommand(
context.Message.EventId,
context.Message.PersonId), context.CancellationToken);
if (context.Message.PreviousEventId != context.Message.EventId ||
context.Message.PreviousPersonId != context.Message.PersonId)
{
await mediator.Send(new SynchronizeCertificateCommand(
context.Message.PreviousEventId,
context.Message.PreviousPersonId), context.CancellationToken);
}
}
public Task Consume(ConsumeContext<RegistrationDeleted> context)
{
return mediator.Send(new SynchronizeCertificateCommand(
context.Message.EventId,
context.Message.PersonId), context.CancellationToken);
}
}

View File

@@ -0,0 +1,32 @@
using System.Globalization;
using Events.FilesAPI.Infrastructure.Files;
using Events.FilesAPI.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace Events.FilesAPI.Features.Certificates.Download;
public sealed class CertificateFileLocator(
IHostEnvironment hostEnvironment,
IOptions<GeneratedFilesOptions> generatedFilesOptions)
{
public GeneratedFileReference? TryGet(int eventId, int personId)
{
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
? generatedFilesOptions.Value.OutputPath
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
string certificatePath = Path.Combine(
rootPath,
eventId.ToString(CultureInfo.InvariantCulture),
$"{eventId}-{personId}.pdf");
if (!File.Exists(certificatePath))
return null;
return new GeneratedFileReference
{
FileName = Path.GetFileName(certificatePath),
PhysicalPath = certificatePath
};
}
}

View File

@@ -0,0 +1,35 @@
using Events.FilesAPI.Infrastructure.Files;
using Events.FilesAPI.Features.Certificates.Synchronize;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Events.FilesAPI.Features.Certificates.Download;
public sealed class DownloadCertificateHandler(
EventsContext context,
CertificateFileGenerator generator,
CertificateFileLocator fileLocator) : IRequestHandler<DownloadCertificateQuery, DownloadCertificateResult>
{
public async Task<DownloadCertificateResult> Handle(DownloadCertificateQuery request, CancellationToken cancellationToken)
{
var registration = await context.Registrations
.AsNoTracking()
.Where(r => r.Id == request.RegistrationId)
.Select(r => new { r.EventId, r.PersonId })
.SingleOrDefaultAsync(cancellationToken);
if (registration == null)
return new DownloadCertificateResult(false, null);
GeneratedFileReference? file = fileLocator.TryGet(registration.EventId, registration.PersonId);
if (file == null)
{
await generator.GenerateAsync(registration.EventId, registration.PersonId, cancellationToken);
file = fileLocator.TryGet(registration.EventId, registration.PersonId);
}
return new DownloadCertificateResult(true, file);
}
}

View File

@@ -0,0 +1,8 @@
using Events.FilesAPI.Infrastructure.Files;
using MediatR;
namespace Events.FilesAPI.Features.Certificates.Download;
public sealed record DownloadCertificateQuery(int RegistrationId) : IRequest<DownloadCertificateResult>;
public sealed record DownloadCertificateResult(bool RegistrationFound, GeneratedFileReference? File);

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Events.FilesAPI.Features.Certificates.Download;
using MediatR;
namespace Events.FilesAPI.Features.Certificates;
[ApiController]
[Route("Registrations")]
public class DownloadCertificateController : ControllerBase
{
private const string PdfContentType = "application/pdf";
[HttpGet("{id}/Certificate")]
[ProducesResponseType(typeof(PhysicalFileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadCertificate(
int id,
[FromServices] IMediator mediator,
CancellationToken cancellationToken)
{
DownloadCertificateResult result = await mediator.Send(new DownloadCertificateQuery(id), cancellationToken);
if (!result.RegistrationFound)
return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}");
if (result.File == null)
return Problem(statusCode: StatusCodes.Status404NotFound, detail: "Certificate could not be generated.");
return PhysicalFile(result.File.PhysicalPath, PdfContentType, result.File.FileName);
}
}

View File

@@ -0,0 +1,123 @@
using System.Globalization;
using Events.FilesAPI.Infrastructure.Options;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Events.WebAPI.Handlers.EF.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Events.FilesAPI.Features.Certificates.Synchronize;
public sealed class CertificateFileGenerator(
EventsContext context,
IHostEnvironment hostEnvironment,
IOptions<GeneratedFilesOptions> generatedFilesOptions,
ILogger<CertificateFileGenerator> logger)
{
public async Task GenerateAsync(int eventId, int personId, CancellationToken cancellationToken)
{
var registrations = await context.Set<Registration>()
.AsNoTracking()
.Where(r => r.EventId == eventId && r.PersonId == personId)
.OrderBy(r => r.Sport.Name)
.Select(r => new CertificateRegistrationData
{
EventId = r.EventId,
EventName = r.Event.Name,
EventDate = r.Event.EventDate,
PersonId = r.PersonId,
FirstName = r.Person.FirstName,
LastName = r.Person.LastName,
FirstNameTranscription = r.Person.FirstNameTranscription,
LastNameTranscription = r.Person.LastNameTranscription,
SportName = r.Sport.Name
})
.ToListAsync(cancellationToken);
string certificatePath = GetCertificatePath(eventId, personId);
if (registrations.Count == 0)
{
DeleteCertificateIfExists(certificatePath);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
byte[] pdfBytes = CertificatePdfDocumentWriter.CreateCertificate(BuildModel(registrations));
await File.WriteAllBytesAsync(certificatePath, pdfBytes, cancellationToken);
logger.LogInformation(
"Registration certificate generated for event #{EventId}, person #{PersonId} at {Path}",
eventId,
personId,
certificatePath);
}
private string GetCertificatePath(int eventId, int personId)
{
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
? generatedFilesOptions.Value.OutputPath
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
return Path.Combine(rootPath, eventId.ToString(CultureInfo.InvariantCulture), $"{eventId}-{personId}.pdf");
}
private void DeleteCertificateIfExists(string certificatePath)
{
DeleteFileIfExists(certificatePath, "Registration certificate deleted at {Path}");
string? directory = Path.GetDirectoryName(certificatePath);
if (!string.IsNullOrWhiteSpace(directory) &&
Directory.Exists(directory) &&
!Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
}
}
private void DeleteFileIfExists(string path, string logMessage)
{
if (!File.Exists(path))
return;
File.Delete(path);
logger.LogInformation(logMessage, path);
}
private static CertificatePdfModel BuildModel(IReadOnlyList<CertificateRegistrationData> registrations)
{
CertificateRegistrationData first = registrations[0];
string originalFullName = $"{first.FirstName} {first.LastName}".Trim();
string transcriptionFullName = $"{first.FirstNameTranscription} {first.LastNameTranscription}".Trim();
var sports = registrations
.Select(r => r.SportName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new CertificatePdfModel
{
Title = "Certificate of participation",
PersonFullName = originalFullName,
PersonFullNameTranscription = transcriptionFullName,
EventName = first.EventName,
EventDate = first.EventDate,
EventId = first.EventId,
PersonId = first.PersonId,
SportNames = sports
};
}
private sealed class CertificateRegistrationData
{
public int EventId { get; init; }
public string EventName { get; init; } = string.Empty;
public DateOnly EventDate { get; init; }
public int PersonId { get; init; }
public string? FirstName { get; init; } = string.Empty;
public string? LastName { get; init; } = string.Empty;
public string FirstNameTranscription { get; init; } = string.Empty;
public string LastNameTranscription { get; init; } = string.Empty;
public string SportName { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace Events.FilesAPI.Features.Certificates.Synchronize;
public sealed record SynchronizeCertificateCommand(int EventId, int PersonId) : IRequest;

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace Events.FilesAPI.Features.Certificates.Synchronize;
public sealed class SynchronizeCertificateHandler(
CertificateFileGenerator generator) : IRequestHandler<SynchronizeCertificateCommand>
{
public async Task Handle(SynchronizeCertificateCommand request, CancellationToken cancellationToken)
{
await generator.GenerateAsync(request.EventId, request.PersonId, cancellationToken);
}
}