Compare commits

..

9 Commits

Author SHA1 Message Date
Boris Milašinović
fedb24e41c Ignore lscache files 2026-05-15 09:16:23 +02:00
Boris Milašinović
80dd9aff39 token cache location set to localstorage instead of memory 2026-05-14 14:22:00 +02:00
Boris Milašinović
6ad33472e9 Fix person validation in Events.WebApi 2026-05-14 12:57:49 +02:00
Boris Milašinović
3e0fc96c0e GraphQL bugfix + query samples 2026-05-12 20:41:06 +02:00
Boris Milašinović
3f2e199ec4 Events-WebAPI fix and tweak 2026-05-12 17:23:45 +02:00
Boris Milašinović
b66d05c298 Fix and cleanup for Events.WebApi 2026-05-12 02:20:00 +02:00
Boris Milašinović
4fb3de19f6 WebApi + ClientApp, GraphQL, Reflection 2026-05-10 23:39:55 +02:00
Boris Milašinović
8f7c704a90 Test Postgres database definition 2026-04-29 16:48:15 +02:00
Boris Milašinović
b849c6feb6 Minor changes 2026-04-29 16:18:03 +02:00
230 changed files with 13973 additions and 91 deletions

2
.gitignore vendored
View File

@@ -501,9 +501,11 @@ typings/
#bower and libman wwwroot/lib #bower and libman wwwroot/lib
**/wwwroot/lib **/wwwroot/lib
**/logs/internal-nlog.txt **/logs/internal-nlog.txt
Events-WebApi/Events.FilesAPI/GeneratedFiles/
# some needed exceptions # some needed exceptions
/DataGenerator/.dotnet/.dotnet /DataGenerator/.dotnet/.dotnet
!**/Properties/launchSettings.json !**/Properties/launchSettings.json
**/.env.local **/.env.local
**/.env **/.env
*.lscache

View File

@@ -28,6 +28,8 @@ public static class Constants
public const string HeaderActionLabel = "HeaderActionLabel"; public const string HeaderActionLabel = "HeaderActionLabel";
public const string HeaderActionTarget = "HeaderActionTarget"; public const string HeaderActionTarget = "HeaderActionTarget";
public const string CreatePersonModel = "CreatePersonModel"; public const string CreatePersonModel = "CreatePersonModel";
public const string PeopleCountryOptions = "PeopleCountryOptions";
public const string PeopleCountryFilter = "PeopleCountryFilter";
public const string Prefix = "Prefix"; public const string Prefix = "Prefix";
public const string CanRemoveRows = "CanRemoveRows"; public const string CanRemoveRows = "CanRemoveRows";
} }

View File

@@ -22,7 +22,7 @@ public class CountriesController : Controller
private readonly ISieveProcessor sieveProcessor; private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public CountriesController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions<PagingSettings> pagingSettings) public CountriesController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.ctx = ctx; this.ctx = ctx;
this.sieveProcessor = sieveProcessor; this.sieveProcessor = sieveProcessor;

View File

@@ -22,7 +22,7 @@ public class EventsController : Controller
private readonly ISieveProcessor sieveProcessor; private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public EventsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions<PagingSettings> pagingSettings) public EventsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.ctx = ctx; this.ctx = ctx;
this.sieveProcessor = sieveProcessor; this.sieveProcessor = sieveProcessor;

View File

@@ -23,7 +23,7 @@ public class PeopleController : Controller
private readonly ISieveProcessor sieveProcessor; private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public PeopleController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions<PagingSettings> pagingSettings) public PeopleController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.ctx = ctx; this.ctx = ctx;
this.sieveProcessor = sieveProcessor; this.sieveProcessor = sieveProcessor;
@@ -40,6 +40,7 @@ public class PeopleController : Controller
return RedirectToAction("Index", "Countries"); return RedirectToAction("Index", "Countries");
} }
await PopulatePeopleCountryFilterViewDataAsync(sieveModel.Filters);
var viewModel = await BuildPeopleListAsync(sieveModel); var viewModel = await BuildPeopleListAsync(sieveModel);
if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request))
{ {
@@ -155,6 +156,7 @@ public class PeopleController : Controller
} }
}); });
await PopulatePeopleCountryFilterViewDataAsync(sieveModel.Filters);
var viewModel = await BuildPeopleListAsync(sieveModel); var viewModel = await BuildPeopleListAsync(sieveModel);
return PartialView("_PeopleList", viewModel); return PartialView("_PeopleList", viewModel);
} }
@@ -248,16 +250,16 @@ public class PeopleController : Controller
} }
}); });
await PopulatePeopleCountryFilterViewDataAsync(sieveModel.Filters);
var viewModel = await BuildPeopleListAsync(sieveModel); var viewModel = await BuildPeopleListAsync(sieveModel);
return PartialView("_PeopleList", viewModel); return PartialView("_PeopleList", viewModel);
} }
private async Task<PeoplePageViewModel> BuildPeopleListAsync(SieveModel sieveModel) private async Task<PagedList<PersonViewModel>> BuildPeopleListAsync(SieveModel sieveModel)
{ {
sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "LastNameTranscription"); sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "LastNameTranscription");
var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty;
var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "FullNameTranscription"); var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "FullNameTranscription");
var countryFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "CountryCode", "==");
var baseQuery = ctx.People var baseQuery = ctx.People
.Select(p => new PersonViewModel .Select(p => new PersonViewModel
@@ -308,12 +310,7 @@ public class PeopleController : Controller
.Apply(sieveModel, baseQuery) .Apply(sieveModel, baseQuery)
.ToListAsync(); .ToListAsync();
return new PeoplePageViewModel return new PagedList<PersonViewModel>(people, pagingInfo);
{
People = new PagedList<PersonViewModel>(people, pagingInfo),
CountryOptions = await GetCountryOptionsAsync(countryFilter),
CountryFilter = countryFilter
};
} }
private async Task<List<SelectListItem>> GetCountryOptionsAsync(string? selectedCode = null) private async Task<List<SelectListItem>> GetCountryOptionsAsync(string? selectedCode = null)
@@ -329,4 +326,11 @@ public class PeopleController : Controller
.ToListAsync(); .ToListAsync();
} }
private async Task PopulatePeopleCountryFilterViewDataAsync(string? filters)
{
var countryFilter = SieveModelExtensions.ExtractFilterValue(filters?.Trim() ?? string.Empty, "CountryCode", "==");
ViewData[Constants.ViewDataKeys.PeopleCountryOptions] = await GetCountryOptionsAsync(countryFilter);
ViewData[Constants.ViewDataKeys.PeopleCountryFilter] = countryFilter;
}
} }

View File

@@ -23,7 +23,7 @@ public class RegistrationsController : Controller
private readonly ISieveProcessor sieveProcessor; private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public RegistrationsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions<PagingSettings> pagingSettings) public RegistrationsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.ctx = ctx; this.ctx = ctx;
this.sieveProcessor = sieveProcessor; this.sieveProcessor = sieveProcessor;

View File

@@ -22,7 +22,7 @@ public class SportsController : Controller
private readonly ISieveProcessor sieveProcessor; private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public SportsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions<PagingSettings> pagingSettings) public SportsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.ctx = ctx; this.ctx = ctx;
this.sieveProcessor = sieveProcessor; this.sieveProcessor = sieveProcessor;

View File

@@ -1,12 +0,0 @@
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Events.MVC.Models.People;
public class PeoplePageViewModel
{
public PagedList<PersonViewModel> People { get; set; } = new([], new PagingInfo());
public List<SelectListItem> CountryOptions { get; set; } = [];
public string CountryFilter { get; set; } = string.Empty;
}

View File

@@ -14,7 +14,7 @@ public class PagerTagHelper : TagHelper
private readonly IUrlHelperFactory urlHelperFactory; private readonly IUrlHelperFactory urlHelperFactory;
private readonly PagingSettings pagingSettings; private readonly PagingSettings pagingSettings;
public PagerTagHelper(IUrlHelperFactory urlHelperFactory, IOptions<PagingSettings> pagingSettings) public PagerTagHelper(IUrlHelperFactory urlHelperFactory, IOptionsSnapshot<PagingSettings> pagingSettings)
{ {
this.urlHelperFactory = urlHelperFactory; this.urlHelperFactory = urlHelperFactory;
this.pagingSettings = pagingSettings.Value; this.pagingSettings = pagingSettings.Value;

View File

@@ -1,4 +1,4 @@
@model Events.MVC.Models.People.PeoplePageViewModel @model PagedList<Events.MVC.Models.People.PersonViewModel>
@{ @{
ViewData[Constants.ViewDataKeys.Title] = "People"; ViewData[Constants.ViewDataKeys.Title] = "People";

View File

@@ -1,13 +1,18 @@
@model Events.MVC.Models.People.PeoplePageViewModel @model PagedList<Events.MVC.Models.People.PersonViewModel>
@{
var countryFilter = ViewData[Constants.ViewDataKeys.PeopleCountryFilter] as string ?? string.Empty;
var countryOptions = ViewData[Constants.ViewDataKeys.PeopleCountryOptions] as IEnumerable<SelectListItem>
?? Enumerable.Empty<SelectListItem>();
}
<section class="card border-0 shadow-sm" id="people-list"> <section class="card border-0 shadow-sm" id="people-list">
<div class="card-body"> <div class="card-body">
<div id="people-state" class="d-none"> <div id="people-state" class="d-none">
<input type="hidden" name="page" value="@Model.People.PagingInfo.CurrentPage" /> <input type="hidden" name="page" value="@Model.PagingInfo.CurrentPage" />
<input type="hidden" name="pageSize" value="@Model.People.PagingInfo.ItemsPerPage" /> <input type="hidden" name="pageSize" value="@Model.PagingInfo.ItemsPerPage" />
<input type="hidden" name="sorts" value="@Model.People.PagingInfo.Sorts" /> <input type="hidden" name="sorts" value="@Model.PagingInfo.Sorts" />
<input type="hidden" name="filters" value="@Model.People.PagingInfo.Filters" /> <input type="hidden" name="filters" value="@Model.PagingInfo.Filters" />
<input type="hidden" name="countryFilter" value="@Model.CountryFilter" /> <input type="hidden" name="countryFilter" value="@countryFilter" />
</div> </div>
<form <form
@@ -20,40 +25,40 @@
hx-push-url="true" hx-push-url="true"
onsubmit="var input=this.querySelector('[data-name-filter-input]'); var country=this.querySelector('[name=countryFilter]'); var filters=this.querySelector('[name=filters]'); var values=[]; if (input && input.value.trim()) { values.push('FullNameTranscription' + String.fromCharCode(64) + '=*' + input.value.trim()); } if (country && country.value) { values.push('CountryCode==' + country.value); } filters.value=values.join(','); this.querySelector('[name=page]').value='1';"> onsubmit="var input=this.querySelector('[data-name-filter-input]'); var country=this.querySelector('[name=countryFilter]'); var filters=this.querySelector('[name=filters]'); var values=[]; if (input && input.value.trim()) { values.push('FullNameTranscription' + String.fromCharCode(64) + '=*' + input.value.trim()); } if (country && country.value) { values.push('CountryCode==' + country.value); } filters.value=values.join(','); this.querySelector('[name=page]').value='1';">
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value="@Model.People.PagingInfo.ItemsPerPage" /> <input type="hidden" name="pageSize" value="@Model.PagingInfo.ItemsPerPage" />
<input type="hidden" name="sorts" value="@Model.People.PagingInfo.Sorts" /> <input type="hidden" name="sorts" value="@Model.PagingInfo.Sorts" />
<input type="hidden" name="filters" value="@Model.People.PagingInfo.Filters" /> <input type="hidden" name="filters" value="@Model.PagingInfo.Filters" />
<div class="d-flex align-items-center gap-3 pt-2"> <div class="d-flex align-items-center gap-3 pt-2">
<h2 class="h5 mb-0">People list</h2> <h2 class="h5 mb-0">People list</h2>
<span class="badge text-bg-light">@(Model.People.PagingInfo.IsFiltered ? $"{Model.People.PagingInfo.FilteredItemsCount} / {Model.People.PagingInfo.TotalItemsCount}" : Model.People.PagingInfo.TotalItemsCount.ToString())</span> <span class="badge text-bg-light">@(Model.PagingInfo.IsFiltered ? $"{Model.PagingInfo.FilteredItemsCount} / {Model.PagingInfo.TotalItemsCount}" : Model.PagingInfo.TotalItemsCount.ToString())</span>
</div> </div>
<div class="d-flex align-items-center gap-2 flex-nowrap ms-auto"> <div class="d-flex align-items-center gap-2 flex-nowrap ms-auto">
<input <input
id="personNameFilter" id="personNameFilter"
value="@Model.People.PagingInfo.NameFilter" value="@Model.PagingInfo.NameFilter"
data-name-filter-input data-name-filter-input
class="form-control" class="form-control"
style="max-width: 18rem;" style="max-width: 18rem;"
placeholder="Search by transcribed full name" placeholder="Search by transcribed full name"
aria-label="Filter by transcribed full name" /> aria-label="Filter by transcribed full name" />
<select name="countryFilter" asp-items="Model.CountryOptions" class="form-select" style="max-width: 14rem;" aria-label="Filter by country"> <select name="countryFilter" asp-items="countryOptions" class="form-select" style="max-width: 14rem;" aria-label="Filter by country">
<option value="">All countries</option> <option value="">All countries</option>
</select> </select>
<button type="submit" class="btn btn-outline-primary">Filter</button> <button type="submit" class="btn btn-outline-primary">Filter</button>
@if (Model.People.PagingInfo.IsFiltered) @if (Model.PagingInfo.IsFiltered)
{ {
<a <a
asp-action="Index" asp-action="Index"
asp-route-page="1" asp-route-page="1"
asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage"
asp-route-sorts="@Model.People.PagingInfo.Sorts" asp-route-sorts="@Model.PagingInfo.Sorts"
asp-route-filters="" asp-route-filters=""
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
hx-get="@Url.Action("Index", "People", new { page = 1, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.Sorts, filters = string.Empty })" hx-get="@Url.Action("Index", "People", new { page = 1, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.Sorts, filters = string.Empty })"
hx-target="#people-list" hx-target="#people-list"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-push-url="true"> hx-push-url="true">
@@ -68,41 +73,41 @@
<thead> <thead>
<tr> <tr>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("Id")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("Id"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("Id")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("Id"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
ID@(Model.People.PagingInfo.IsSortedBy("Id") ? (Model.People.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") ID@(Model.PagingInfo.IsSortedBy("Id") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a> </a>
</th> </th>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("FirstNameTranscription")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("FirstNameTranscription"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("FirstNameTranscription")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("FirstNameTranscription"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
First name@(Model.People.PagingInfo.IsSortedBy("FirstNameTranscription") ? (Model.People.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") First name@(Model.PagingInfo.IsSortedBy("FirstNameTranscription") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a> </a>
</th> </th>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("LastNameTranscription")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("LastNameTranscription"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("LastNameTranscription")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("LastNameTranscription"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
Last name@(Model.People.PagingInfo.IsSortedBy("LastNameTranscription") ? (Model.People.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") Last name@(Model.PagingInfo.IsSortedBy("LastNameTranscription") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a> </a>
</th> </th>
<th></th> <th></th>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("BirthDate")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("BirthDate"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("BirthDate")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("BirthDate"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
Birth date@(Model.People.PagingInfo.IsSortedBy("BirthDate") ? (Model.People.PagingInfo.IsDescending() ? " v" : " ^") : "") Birth date@(Model.PagingInfo.IsSortedBy("BirthDate") ? (Model.PagingInfo.IsDescending() ? " v" : " ^") : "")
</a> </a>
</th> </th>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("CountryName")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("CountryName"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("CountryName")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("CountryName"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
Country@(Model.People.PagingInfo.IsSortedBy("CountryName") ? (Model.People.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") Country@(Model.PagingInfo.IsSortedBy("CountryName") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a> </a>
</th> </th>
<th> <th>
<a asp-action="Index" asp-route-page="@Model.People.PagingInfo.CurrentPage" asp-route-pageSize="@Model.People.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.People.PagingInfo.ToggleSort("RegistrationsCount")" asp-route-filters="@Model.People.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.People.PagingInfo.CurrentPage, pageSize = Model.People.PagingInfo.ItemsPerPage, sorts = Model.People.PagingInfo.ToggleSort("RegistrationsCount"), filters = Model.People.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <a asp-action="Index" asp-route-page="@Model.PagingInfo.CurrentPage" asp-route-pageSize="@Model.PagingInfo.ItemsPerPage" asp-route-sorts="@Model.PagingInfo.ToggleSort("RegistrationsCount")" asp-route-filters="@Model.PagingInfo.Filters" class="link-dark link-underline-opacity-0" hx-get="@Url.Action("Index", "People", new { page = Model.PagingInfo.CurrentPage, pageSize = Model.PagingInfo.ItemsPerPage, sorts = Model.PagingInfo.ToggleSort("RegistrationsCount"), filters = Model.PagingInfo.Filters })" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
Registrations@(Model.People.PagingInfo.IsSortedBy("RegistrationsCount") ? (Model.People.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") Registrations@(Model.PagingInfo.IsSortedBy("RegistrationsCount") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a> </a>
</th> </th>
<th class="text-end"></th> <th class="text-end"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.People.Data.Count == 0) @if (Model.Data.Count == 0)
{ {
<tr> <tr>
<td colspan="8" class="text-body-secondary">No people to display.</td> <td colspan="8" class="text-body-secondary">No people to display.</td>
@@ -110,7 +115,7 @@
} }
else else
{ {
@foreach (var person in Model.People.Data) @foreach (var person in Model.Data)
{ {
<partial name="_PersonRow" model="person" /> <partial name="_PersonRow" model="person" />
} }
@@ -121,24 +126,24 @@
<div class="d-flex justify-content-between align-items-center mt-4 gap-3 flex-wrap"> <div class="d-flex justify-content-between align-items-center mt-4 gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<small class="text-body-secondary">Page @Model.People.PagingInfo.CurrentPage of @Model.People.PagingInfo.TotalPages</small> <small class="text-body-secondary">Page @Model.PagingInfo.CurrentPage of @Model.PagingInfo.TotalPages</small>
<form asp-action="Index" method="get" class="d-inline-flex align-items-center gap-2" hx-get="@Url.Action("Index", "People")" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true"> <form asp-action="Index" method="get" class="d-inline-flex align-items-center gap-2" hx-get="@Url.Action("Index", "People")" hx-target="#people-list" hx-swap="outerHTML" hx-push-url="true">
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />
<input type="hidden" name="sorts" value="@Model.People.PagingInfo.Sorts" /> <input type="hidden" name="sorts" value="@Model.PagingInfo.Sorts" />
<input type="hidden" name="filters" value="@Model.People.PagingInfo.Filters" /> <input type="hidden" name="filters" value="@Model.PagingInfo.Filters" />
<select name="pageSize" class="form-select form-select-sm" style="width: auto;" aria-label="Items per page" onchange="this.form.requestSubmit()"> <select name="pageSize" class="form-select form-select-sm" style="width: auto;" aria-label="Items per page" onchange="this.form.requestSubmit()">
@{ @{
int[] pageSizeOptions = [10, 20, 50, 100]; int[] pageSizeOptions = [10, 20, 50, 100];
} }
@foreach (var option in pageSizeOptions) @foreach (var option in pageSizeOptions)
{ {
<option value="@option" selected="@(Model.People.PagingInfo.ItemsPerPage == option)">@option</option> <option value="@option" selected="@(Model.PagingInfo.ItemsPerPage == option)">@option</option>
} }
</select> </select>
</form> </form>
</div> </div>
<pager <pager
page-info="@Model.People.PagingInfo" page-info="@Model.PagingInfo"
page-action="Index" page-action="Index"
page-title="Enter a page number and press Enter" page-title="Enter a page number and press Enter"
page-target="#people-list" page-target="#people-list"

View File

@@ -56,11 +56,7 @@ internal sealed class UiTestHarness : IAsyncDisposable
CreateNoWindow = true CreateNoWindow = true
}; };
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest"; startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest";
#if POSTGRES
startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString(); startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString();
#else
startInfo.Environment["ConnectionStrings__EventsMssql"] = ResolveUiTestConnectionString();
#endif
var appProcess = Process.Start(startInfo) var appProcess = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests."); ?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests.");
@@ -171,11 +167,11 @@ internal sealed class UiTestHarness : IAsyncDisposable
.AddUserSecrets<Program>(optional: true) .AddUserSecrets<Program>(optional: true)
.Build(); .Build();
var connectionString = configuration.GetConnectionString("EventDB-Test"); var connectionString = configuration.GetConnectionString("EventsPostgres-Test");
if (string.IsNullOrWhiteSpace(connectionString)) if (string.IsNullOrWhiteSpace(connectionString))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"The EventDB-Test connection string must be available so UI tests can connect to the selected provider's test database."); "The EventsPostgres-Test connection string must be available so UI tests can connect to the PostgreSQL test database on port 5433.");
} }
return connectionString; return connectionString;

View File

@@ -39,5 +39,5 @@ dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj --
## Notes ## Notes
- The UI test harness starts the MVC application automatically - The UI test harness starts the MVC application automatically
- UI tests connect the MVC application to the selected provider's test database from `ConnectionStrings:EventDB-Test` - UI tests connect the MVC application to the PostgreSQL test database on port `5433` from `ConnectionStrings:EventsPostgres-Test`
- The browser is currently configured in headless mode - The browser is currently configured in headless mode

View File

@@ -9,6 +9,7 @@ using Events.MVC.Models;
using Events.MVC.Models.People; using Events.MVC.Models.People;
using Events.Tests.UnitTests.Infrastructure; using Events.Tests.UnitTests.Infrastructure;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Sieve.Models; using Sieve.Models;
@@ -59,8 +60,8 @@ public class PeopleControllerShould
var partial = Assert.IsType<PartialViewResult>(result); var partial = Assert.IsType<PartialViewResult>(result);
Assert.Equal("_PeopleList", partial.ViewName); Assert.Equal("_PeopleList", partial.ViewName);
var model = Assert.IsType<PeoplePageViewModel>(partial.Model); var model = Assert.IsType<PagedList<PersonViewModel>>(partial.Model);
Assert.Contains(model.People.Data, p => p.FullName == "Ana Kovac" && p.CountryName == "Croatia"); Assert.Contains(model.Data, p => p.FullName == "Ana Kovac" && p.CountryName == "Croatia");
Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
} }
@@ -100,8 +101,8 @@ public class PeopleControllerShould
var partial = Assert.IsType<PartialViewResult>(result); var partial = Assert.IsType<PartialViewResult>(result);
Assert.Equal("_PeopleList", partial.ViewName); Assert.Equal("_PeopleList", partial.ViewName);
var model = Assert.IsType<PeoplePageViewModel>(partial.Model); var model = Assert.IsType<PagedList<PersonViewModel>>(partial.Model);
var person = Assert.Single(model.People.Data); var person = Assert.Single(model.Data);
Assert.Equal("Ana Kovac", person.FullName); Assert.Equal("Ana Kovac", person.FullName);
Assert.Equal("Croatia", person.CountryName); Assert.Equal("Croatia", person.CountryName);
} }
@@ -178,8 +179,8 @@ public class PeopleControllerShould
}); });
var view = Assert.IsType<ViewResult>(result); var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<PeoplePageViewModel>(view.Model); var model = Assert.IsType<PagedList<PersonViewModel>>(view.Model);
var people = Assert.Single(model.People.Data); var people = Assert.Single(model.Data);
Assert.Equal(1, people.Id); Assert.Equal(1, people.Id);
Assert.Equal("Ђорђе Петровић", people.FullName); Assert.Equal("Ђорђе Петровић", people.FullName);
} }
@@ -203,11 +204,12 @@ public class PeopleControllerShould
}); });
var view = Assert.IsType<ViewResult>(result); var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<PeoplePageViewModel>(view.Model); var model = Assert.IsType<PagedList<PersonViewModel>>(view.Model);
var person = Assert.Single(model.People.Data); var person = Assert.Single(model.Data);
Assert.Equal("Ana Novak", person.FullName); Assert.Equal("Ana Novak", person.FullName);
Assert.Equal("SI", model.CountryFilter); Assert.Equal("SI", Assert.IsType<string>(view.ViewData[Events.MVC.Constants.ViewDataKeys.PeopleCountryFilter]));
Assert.Contains(model.CountryOptions, option => option.Value == "SI" && option.Selected); var countryOptions = Assert.IsAssignableFrom<IEnumerable<SelectListItem>>(view.ViewData[Events.MVC.Constants.ViewDataKeys.PeopleCountryOptions]);
Assert.Contains(countryOptions, option => option.Value == "SI" && option.Selected);
} }
[Fact] [Fact]

View File

@@ -71,7 +71,7 @@ public class SportsControllerShould
new Sport { Id = 3, Name = "Cycling" }); new Sport { Id = 3, Name = "Cycling" });
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
var optionsMock = new Mock<IOptions<PagingSettings>>(); var optionsMock = new Mock<IOptionsSnapshot<PagingSettings>>();
optionsMock optionsMock
.SetupGet(options => options.Value) .SetupGet(options => options.Value)
.Returns(new PagingSettings { PageSize = 2 }); .Returns(new PagingSettings { PageSize = 2 });

View File

@@ -24,9 +24,9 @@ internal static class ControllerTestContext
return new EventsContext(options); return new EventsContext(options);
} }
public static IOptions<PagingSettings> CreatePagingOptions(int pageSize = 10) public static IOptionsSnapshot<PagingSettings> CreatePagingOptions(int pageSize = 10)
{ {
return Options.Create(new PagingSettings { PageSize = pageSize }); return new TestOptionsSnapshot<PagingSettings>(new PagingSettings { PageSize = pageSize });
} }
public static SieveModel EmptySieveModel() public static SieveModel EmptySieveModel()
@@ -95,4 +95,12 @@ internal static class ControllerTestContext
Name = name Name = name
}; };
} }
private sealed class TestOptionsSnapshot<TOptions>(TOptions value) : IOptionsSnapshot<TOptions>
where TOptions : class
{
public TOptions Value => value;
public TOptions Get(string? name) => value;
}
} }

View File

@@ -47,10 +47,10 @@ public class ProviderSpecificQueryShould
.AddUserSecrets<ProviderSpecificQueryShould>(optional: true) .AddUserSecrets<ProviderSpecificQueryShould>(optional: true)
.Build(); .Build();
var productionConnectionString = configuration.GetConnectionString("EventDB-Test"); var productionConnectionString = configuration.GetConnectionString("EventsPostgres");
Assert.False( Assert.False(
string.IsNullOrWhiteSpace(productionConnectionString), string.IsNullOrWhiteSpace(productionConnectionString),
"The EventDB-Test connection string must be available so the PostgreSQL-backed provider test can connect to the PostgreSQL copy."); "The EventsPostgres connection string must be available so the PostgreSQL-backed provider test can connect to the PostgreSQL copy.");
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(productionConnectionString) var connectionStringBuilder = new NpgsqlConnectionStringBuilder(productionConnectionString)
{ {

View File

@@ -1,5 +1,5 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"EventDB-Test": "Host=localhost;Port=5433;Database=events;Username=sport;Persist Security Info=True;" "EventsPostgres": "Host=localhost;Port=5433;Database=events;Username=sport;Persist Security Info=True;"
} }
} }

View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="Events.Auth/Events.Auth.csproj" />
<Project Path="Events.FilesAPI/Events.FilesAPI.csproj" />
<Project Path="Events.WebAPI.Contract/Events.WebAPI.Contract.csproj" />
<Project Path="Events.WebAPI.Handlers.EF/Events.WebAPI.Handlers.EF.csproj" />
<Project Path="Events.WebAPI/Events.WebAPI.csproj" />
</Solution>

View File

@@ -0,0 +1,54 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace Events.Auth;
public static class AuthSetupExtensions
{
public static void SetupAuthenticationAndAuthorization(this IServiceCollection services, string authority, string audience)
{
ArgumentException.ThrowIfNullOrWhiteSpace(authority);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddScoped<IClaimsTransformation, ScopeClaimsTransformation>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.Authority = authority;
opt.Audience = audience;
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidateIssuerSigningKey = true,
NameClaimType = ClaimTypes.NameIdentifier
};
opt.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(options =>
{
foreach (var policy in Policies.All)
{
options.AddPolicy(policy.Key, policy.Value);
}
});
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Authorization;
namespace Events.Auth;
public static class Policies
{
public static IEnumerable<KeyValuePair<string, Action<AuthorizationPolicyBuilder>>> All
{
get
{
yield return new KeyValuePair<string, Action<AuthorizationPolicyBuilder>>(nameof(ReadData), ReadData);
yield return new KeyValuePair<string, Action<AuthorizationPolicyBuilder>>(nameof(EditData), EditData);
}
}
public static Action<AuthorizationPolicyBuilder> ReadData =>
policy => policy.RequireClaim("scope", "events:read");
public static Action<AuthorizationPolicyBuilder> EditData =>
policy => policy.RequireClaim("scope", "events:write");
}

View File

@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
namespace Events.Auth;
public sealed class ScopeClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
{
return Task.FromResult(principal);
}
Claim[] combinedScopeClaims = identity
.FindAll("scope")
.Where(claim => claim.Value.Contains(' '))
.ToArray();
if (combinedScopeClaims.Length == 0)
{
return Task.FromResult(principal);
}
var additionalIdentity = new ClaimsIdentity();
foreach (Claim combinedClaim in combinedScopeClaims)
{
foreach (string scope in combinedClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (identity.HasClaim("scope", scope) || additionalIdentity.HasClaim("scope", scope))
{
continue;
}
additionalIdentity.AddClaim(new Claim("scope", scope, combinedClaim.ValueType, combinedClaim.Issuer));
}
}
if (additionalIdentity.Claims.Any())
{
principal.AddIdentity(additionalIdentity);
}
return Task.FromResult(principal);
}
}

View File

@@ -0,0 +1,6 @@
VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com
VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3
VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api
VITE_AUTH0_SCOPE=openid profile email events:read events:write
VITE_API_BASE_URL=https://localhost:7295
VITE_FILES_API_BASE_URL=https://localhost:7296

View File

@@ -0,0 +1,126 @@
# Events.ClientApp
`Events.ClientApp` is the Vue 3 front-end for the `Events-WebApi` solution.
It uses:
- Vite
- Vue 3
- PrimeVue
- Auth0 Vue SDK
It is intended as a companion UI for:
- `Events.WebAPI` for CRUD and lookup operations
- `Events.FilesAPI` for certificate and Excel downloads
## Scripts
Install dependencies:
```powershell
npm install
```
Start the development server:
```powershell
npm run dev
```
Build for production:
```powershell
npm run build
```
Preview the production build:
```powershell
npm run preview
```
By default, the Vite dev server runs on:
- `http://localhost:5173`
## Environment Configuration
The app reads configuration from Vite environment files.
Typical options are:
- `.env`
- `.env.local`
The project already includes:
- `.env.example`
The simplest setup is to copy `.env.example` to `.env.local` and fill in the real values.
Example:
```powershell
Copy-Item .env.example .env.local
```
## Environment Variables
### Required for Auth0 login
- `VITE_AUTH0_DOMAIN`
Auth0 tenant domain, for example `fer-web2.eu.auth0.com`
- `VITE_AUTH0_CLIENT_ID`
Auth0 client ID for the SPA application
### Optional Auth0 settings
- `VITE_AUTH0_AUDIENCE`
API audience passed to Auth0 when requesting an access token
- `VITE_AUTH0_SCOPE`
Space-separated scopes requested during login
### API configuration
- `VITE_API_BASE_URL`
Base URL of `Events.WebAPI`
- `VITE_FILES_API_BASE_URL`
Base URL of `Events.FilesAPI`
If `VITE_API_BASE_URL` is not set, the app falls back to:
- `https://localhost:7150`
If `VITE_FILES_API_BASE_URL` is not set, the app falls back to:
- `https://localhost:7296`
## Example
```env
VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com
VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3
VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api
VITE_AUTH0_SCOPE=openid profile email events:read events:write
VITE_API_BASE_URL=https://localhost:7295
VITE_FILES_API_BASE_URL=https://localhost:7296
```
## Notes
- `VITE_AUTH0_DOMAIN` and `VITE_AUTH0_CLIENT_ID` are required if you want the Auth0 login flow to work.
- `VITE_AUTH0_AUDIENCE` and `VITE_AUTH0_SCOPE` are optional in code, but are usually needed if the APIs expect bearer tokens with a specific audience and scopes.
- `VITE_API_BASE_URL` should point to the running `Events.WebAPI` instance for local development.
- `VITE_FILES_API_BASE_URL` should point to the running `Events.FilesAPI` instance for local development.
- `.env.local` is for local development and should not be treated as a shared secrets file.
## What The Client Demonstrates
- login and token acquisition through Auth0
- calling secured `Events.WebAPI` endpoints
- downloading protected files from `Events.FilesAPI`
- local development against separately running ASP.NET Core backends

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Events Client</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "events-clientapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@auth0/auth0-vue": "^2.5.0",
"@primeuix/themes": "^2.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.8"
}
}

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAuth0 } from '@auth0/auth0-vue';
import Button from 'primevue/button';
import ConfirmDialog from 'primevue/confirmdialog';
import Menu from 'primevue/menu';
import Tab from 'primevue/tab';
import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs';
import Toast from 'primevue/toast';
import EventsPanel from './components/EventsPanel.vue';
import PeoplePanel from './components/PeoplePanel.vue';
import RegistrationsPanel from './components/RegistrationsPanel.vue';
import SportsPanel from './components/SportsPanel.vue';
import { activeTab } from './state/uiState';
const {
isLoading,
isAuthenticated,
error,
loginWithRedirect,
logout: auth0Logout,
user
} = useAuth0();
const sportsPanelRef = ref<{ openCreate: () => void } | null>(null);
const eventsPanelRef = ref<{ openCreate: () => void } | null>(null);
const peoplePanelRef = ref<{ openCreate: () => void } | null>(null);
const registrationsPanelRef = ref<{ openCreate: () => void } | null>(null);
const userMenuRef = ref<{ toggle: (event: Event) => void } | null>(null);
const actionLabel = computed(() => {
switch (activeTab.value) {
case 'events':
return 'New event';
case 'people':
return 'New person';
case 'registrations':
return 'New registration';
case 'sports':
default:
return 'New sport';
}
});
function openActiveCreate() {
switch (activeTab.value) {
case 'events':
eventsPanelRef.value?.openCreate();
break;
case 'people':
peoplePanelRef.value?.openCreate();
break;
case 'registrations':
registrationsPanelRef.value?.openCreate();
break;
case 'sports':
default:
sportsPanelRef.value?.openCreate();
break;
}
}
function login() {
return loginWithRedirect();
}
function logout() {
return auth0Logout({ logoutParams: { returnTo: window.location.origin } });
}
const userMenuItems = [
{
label: 'Logout',
icon: 'pi pi-sign-out',
command: logout
}
];
function toggleUserMenu(event: Event) {
userMenuRef.value?.toggle(event);
}
</script>
<template>
<div class="app-shell">
<Toast position="top-right" />
<ConfirmDialog />
<Menu ref="userMenuRef" :model="userMenuItems" popup />
<section v-if="isLoading" class="workspace auth-state">
<div class="panel-card auth-card">Loading...</div>
</section>
<section v-else-if="!isAuthenticated" class="workspace auth-state">
<div class="panel-card auth-card">
<h1>Events</h1>
<p v-if="error">Error: {{ error.message }}</p>
<div class="inline-actions">
<Button label="Login" @click="login" />
</div>
</div>
</section>
<section v-else class="workspace">
<Tabs v-model:value="activeTab">
<div class="tabs-header-bar">
<TabList>
<Tab value="sports">Sports</Tab>
<Tab value="events">Events</Tab>
<Tab value="people">People</Tab>
<Tab value="registrations">Registrations</Tab>
</TabList>
<div class="tabs-header-center">
<Button :label="actionLabel" icon="pi pi-plus" @click="openActiveCreate" />
</div>
<div class="tabs-header-actions">
<button class="user-chip user-chip-button" type="button" @click="toggleUserMenu($event)">
{{ user?.email || user?.name || 'Authenticated user' }}
<span class="pi pi-angle-down" />
</button>
</div>
</div>
<TabPanels>
<TabPanel value="sports">
<SportsPanel ref="sportsPanelRef" />
</TabPanel>
<TabPanel value="events">
<EventsPanel ref="eventsPanelRef" />
</TabPanel>
<TabPanel value="people">
<PeoplePanel ref="peoplePanelRef" />
</TabPanel>
<TabPanel value="registrations">
<RegistrationsPanel ref="registrationsPanelRef" />
</TabPanel>
</TabPanels>
</Tabs>
</section>
</div>
</template>

View File

@@ -0,0 +1,61 @@
import { deleteJson, getFile, getJson, postJson, putJson } from './http';
import type {
EventDto,
IdName,
ItemsResponse,
PageState,
PersonDto,
RegistrationDto,
RegistrationUpsertDto,
SportDto
} from './types';
function toQuery(pageState: PageState) {
return {
page: pageState.page,
pageSize: pageState.pageSize,
sort: pageState.sort,
sortOrder: pageState.sortOrder,
filters: pageState.filters
};
}
export const sportsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<SportDto>>('/Sports', toQuery(pageState)),
get: (id: number) => getJson<SportDto>(`/Sports/${id}`),
create: (payload: SportDto) => postJson<SportDto, SportDto>('/Sports', payload),
update: (payload: SportDto) => putJson(`/Sports/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Sports/${id}`)
};
export const eventsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<EventDto>>('/Events', toQuery(pageState)),
get: (id: number) => getJson<EventDto>(`/Events/${id}`),
create: (payload: EventDto) => postJson<EventDto, EventDto>('/Events', payload),
update: (payload: EventDto) => putJson(`/Events/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Events/${id}`),
downloadRegistrationsExcel: (id: number) => getFile(`/Events/${id}/RegistrationsExcel`)
};
export const peopleApi = {
list: (pageState: PageState) => getJson<ItemsResponse<PersonDto>>('/People', toQuery(pageState)),
get: (id: number) => getJson<PersonDto>(`/People/${id}`),
create: (payload: PersonDto) => postJson<PersonDto, PersonDto>('/People', payload),
update: (payload: PersonDto) => putJson(`/People/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/People/${id}`)
};
export const registrationsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<RegistrationDto>>('/Registrations', toQuery(pageState)),
get: (id: number) => getJson<RegistrationDto>(`/Registrations/${id}`),
create: (payload: RegistrationUpsertDto) => postJson<RegistrationDto, RegistrationUpsertDto>('/Registrations', payload),
update: (payload: RegistrationUpsertDto) => putJson(`/Registrations/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Registrations/${id}`),
downloadCertificate: (id: number) => getFile(`/Registrations/${id}/Certificate`)
};
export const lookupApi = {
countries: (text?: string) => getJson<Array<IdName<string>>>('/Lookup/Countries', { text }),
people: (text?: string, countryCode?: string) =>
getJson<Array<IdName<number>>>('/Lookup/People', { text, countryCode })
};

View File

@@ -0,0 +1,144 @@
import { auth0 } from '../auth';
import type { ProblemDetails } from './types';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://localhost:7150';
const filesApiBaseUrl = import.meta.env.VITE_FILES_API_BASE_URL ?? 'https://localhost:7296';
function toCamelCase(value: string) {
return value.length > 0 ? value[0].toLowerCase() + value.slice(1) : value;
}
function normalizeJson<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeJson(item)) as T;
}
if (value && typeof value === 'object' && !(value instanceof Date)) {
const normalizedEntries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
toCamelCase(key),
normalizeJson(entryValue)
]);
return Object.fromEntries(normalizedEntries) as T;
}
return value;
}
function buildUrl(
path: string,
query?: Record<string, string | number | undefined | null>,
baseUrl: string = apiBaseUrl
) {
const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
async function parseResponse<T>(response: Response): Promise<T> {
if (response.ok) {
if (response.status === 204) {
return undefined as T;
}
const payload = (await response.json()) as T;
return normalizeJson(payload);
}
let problem: ProblemDetails | undefined;
try {
problem = normalizeJson((await response.json()) as ProblemDetails);
} catch {
problem = undefined;
}
const validationMessage = problem?.errors
? Object.entries(problem.errors)
.flatMap(([field, messages]) => messages.map((message) => (field ? `${field}: ${message}` : message)))
.join('\n')
: undefined;
throw new Error(validationMessage || problem?.detail || problem?.title || `HTTP ${response.status}`);
}
async function buildAuthHeaders() {
if (!auth0.isAuthenticated.value) {
return {} as Record<string, string>;
}
const accessToken = await auth0.getAccessTokenSilently();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : ({} as Record<string, string>);
}
export async function getJson<T>(path: string, query?: Record<string, string | number | undefined | null>) {
const response = await fetch(buildUrl(path, query), {
headers: await buildAuthHeaders()
});
return parseResponse<T>(response);
}
export async function postJson<TResponse, TBody>(path: string, body: TBody) {
const response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
...(await buildAuthHeaders()),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return parseResponse<TResponse>(response);
}
export async function putJson<TBody>(path: string, body: TBody) {
const response = await fetch(buildUrl(path), {
method: 'PUT',
headers: {
...(await buildAuthHeaders()),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return parseResponse<void>(response);
}
export async function deleteJson(path: string) {
const response = await fetch(buildUrl(path), {
method: 'DELETE',
headers: await buildAuthHeaders()
});
return parseResponse<void>(response);
}
export async function getFile(
path: string,
query?: Record<string, string | number | undefined | null>
) {
const response = await fetch(buildUrl(path, query, filesApiBaseUrl), {
headers: await buildAuthHeaders()
});
if (!response.ok) {
await parseResponse<void>(response);
}
const contentDisposition = response.headers.get('Content-Disposition') ?? '';
const fileNameMatch =
contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) ??
contentDisposition.match(/filename="?([^";]+)"?/i);
return {
blob: await response.blob(),
fileName: fileNameMatch?.[1] ? decodeURIComponent(fileNameMatch[1]) : 'download.bin'
};
}

View File

@@ -0,0 +1,79 @@
export interface ItemsResponse<T> {
data: T[] | null;
count: number;
}
export interface IdName<T> {
id: T;
name: string;
description?: string | null;
}
export interface SportDto {
id: number;
name: string;
}
export interface EventDto {
id: number;
name: string;
eventDate: string;
registrationsCount: number;
}
export interface PersonDto {
id: number;
firstName: string;
lastName: string;
firstNameTranscription: string;
lastNameTranscription: string;
addressLine: string;
postalCode: string;
city: string;
addressCountry: string;
email: string;
contactPhone: string;
birthDate: string;
documentNumber: string;
countryCode: string;
countryName: string;
fullNameTranscription: string;
registrationsCount: number;
}
export interface RegistrationDto {
id: number;
eventId: number;
personId: number;
sportId: number;
registeredAt: string | null;
personName: string;
personTranscription: string;
personFirstNameTranscription: string;
personLastNameTranscription: string;
countryCode: string;
countryName: string;
sportName: string;
}
export interface RegistrationUpsertDto {
id: number;
eventId: number;
personId: number;
sportId: number;
}
export interface ProblemDetails {
title?: string;
detail?: string;
errors?: Record<string, string[]>;
errorCodes?: Record<string, string[]>;
}
export interface PageState {
page: number;
pageSize: number;
sort?: string;
sortOrder?: 1 | -1;
filters?: string;
}

View File

@@ -0,0 +1,20 @@
import { createAuth0 } from '@auth0/auth0-vue';
const authorizationParams: Record<string, string> = {
redirect_uri: window.location.origin
};
if (import.meta.env.VITE_AUTH0_AUDIENCE) {
authorizationParams.audience = import.meta.env.VITE_AUTH0_AUDIENCE;
}
if (import.meta.env.VITE_AUTH0_SCOPE) {
authorizationParams.scope = import.meta.env.VITE_AUTH0_SCOPE;
}
export const auth0 = createAuth0({
domain: import.meta.env.VITE_AUTH0_DOMAIN,
clientId: import.meta.env.VITE_AUTH0_CLIENT_ID,
cacheLocation: 'localstorage',
authorizationParams
});

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import DatePicker from 'primevue/datepicker';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { eventsApi } from '../api/eventsApi';
import { eventsCatalogVersion, touchEventsCatalog } from '../state/catalogState';
import { openRegistrationCreateForEvent } from '../state/uiState';
import type { EventDto } from '../api/types';
import { formatDateOnly, toDate, toDateOnlyString } from '../utils/dates';
const rows = ref<EventDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('EventDate');
const sortOrder = ref<1 | -1>(1);
const eventDate = ref<Date | null>(null);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<EventDto>({
id: 0,
name: '',
eventDate: '',
registrationsCount: 0
});
const filters = ref({
Name: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
EventDate: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function toFilterDate(value: unknown) {
if (!value) {
return '';
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().slice(0, 10);
}
return typeof value === 'string' ? value.trim() : '';
}
function buildFilters() {
const result: string[] = [];
result.push(
...filters.value.Name.constraints
.filter((constraint) => (constraint.value ?? '').trim() !== '')
.map((constraint) => `Name${toSieveOperator(constraint.matchMode)}${escapeFilterValue((constraint.value ?? '').trim())}`)
);
result.push(
...filters.value.EventDate.constraints
.map((constraint) => toFilterDate(constraint.value))
.filter((value) => value !== '')
.map((value) => `EventDate==${value}`)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await eventsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load events.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.name = '';
form.eventDate = '';
form.registrationsCount = 0;
eventDate.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
function openRegistrationCreate(id: number) {
openRegistrationCreateForEvent(id);
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await eventsApi.get(id);
form.id = item.id;
form.name = item.name;
form.eventDate = item.eventDate;
eventDate.value = toDate(item.eventDate);
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the event.';
}
}
async function downloadRegistrationsExcel(id: number) {
error.value = '';
try {
const file = await eventsApi.downloadRegistrationsExcel(id);
const url = URL.createObjectURL(file.blob);
const link = document.createElement('a');
link.href = url;
link.download = file.fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to download the registrations Excel file.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
async function save() {
error.value = '';
form.eventDate = toDateOnlyString(eventDate.value);
try {
if (form.id > 0) {
await eventsApi.update({ ...form });
toast.add({ severity: 'success', summary: 'Events', detail: 'Event updated successfully.', life: 3000 });
} else {
await eventsApi.create({ ...form });
toast.add({ severity: 'success', summary: 'Events', detail: 'Event created successfully.', life: 3000 });
}
touchEventsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this event?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await eventsApi.remove(id);
touchEventsCatalog();
toast.add({ severity: 'success', summary: 'Events', detail: 'Event deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(() => {
void loadData();
});
watch(eventsCatalogVersion, () => {
void loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
showCurrentPageReport
:rows="pageSize"
:rowsPerPageOptions="[10, 25, 50, 100]"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
currentPageReportTemplate="{first} to {last} of {totalRecords}"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="eventDate" header="Date" sortable sortField="EventDate" filterField="EventDate" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #body="{ data }">
{{ formatDateOnly(data.eventDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<DatePicker v-model="filterModel.value" date-format="yy-mm-dd" show-icon fluid @date-select="filterCallback()" />
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount">
<template #body="{ data }">
<Button
v-if="(data.registrationsCount ?? 0) > 0"
:label="String(data.registrationsCount ?? 0)"
link
@click="downloadRegistrationsExcel(data.id)"
/>
<span v-else>{{ data.registrationsCount ?? 0 }}</span>
</template>
</Column>
<Column header="" style="width: 16rem">
<template #body="{ data }">
<div class="inline-actions">
<Button label="New registration" link @click="openRegistrationCreate(data.id)" />
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '48rem' }" :header="form.id ? 'Edit event' : 'New event'">
<div class="field-grid">
<div class="field">
<label for="event-name">Name</label>
<InputText id="event-name" v-model="form.name" />
</div>
<div class="field">
<label for="event-date">Date</label>
<DatePicker id="event-date" v-model="eventDate" date-format="dd.mm.yy" show-icon fluid />
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,435 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import DatePicker from 'primevue/datepicker';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { lookupApi, peopleApi } from '../api/eventsApi';
import { touchPeopleCatalog } from '../state/catalogState';
import type { IdName, PersonDto } from '../api/types';
import { formatDateOnly, toDate, toDateOnlyString } from '../utils/dates';
const rows = ref<PersonDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('LastName');
const sortOrder = ref<1 | -1>(1);
const birthDate = ref<Date | null>(null);
const countries = ref<Array<IdName<string>>>([]);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<PersonDto>({
id: 0,
firstName: '',
lastName: '',
firstNameTranscription: '',
lastNameTranscription: '',
addressLine: '',
postalCode: '',
city: '',
addressCountry: '',
email: '',
contactPhone: '',
birthDate: '',
documentNumber: '',
countryCode: '',
countryName: '',
fullNameTranscription: '',
registrationsCount: 0
});
const countryOptions = computed(() =>
countries.value.map((country) => ({
label: country.name,
value: country.id
}))
);
const filters = ref({
Id: {
operator: 'and',
constraints: [{ value: null as number | null, matchMode: 'equals' }]
},
FirstName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
LastName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
FirstNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
LastNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
Email: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
CountryCode: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function getConstraintValues(field: keyof typeof filters.value) {
return filters.value[field].constraints
.map((constraint) => (constraint.value ?? '').toString().trim())
.filter((value) => value !== '');
}
function buildSameFieldFilter(field: string, operator: string, values: string[], logicalOperator: string) {
if (values.length === 0) {
return [];
}
if (values.length === 1) {
return [`${field}${operator}${values[0]}`];
}
if (logicalOperator === 'or') {
return [`${field}${operator}${values.join('|')}`];
}
return values.map((value) => `${field}${operator}${value}`);
}
function buildFilters() {
const result: string[] = [];
const textFields = ['FirstName', 'LastName', 'FirstNameTranscription', 'LastNameTranscription', 'Email'] as const;
for (const field of textFields) {
const groups = new Map<string, string[]>();
for (const constraint of filters.value[field].constraints) {
const value = (constraint.value ?? '').toString().trim();
if (!value) {
continue;
}
const key = constraint.matchMode ?? 'contains';
const entries = groups.get(key) ?? [];
entries.push(escapeFilterValue(value));
groups.set(key, entries);
}
for (const [matchMode, values] of groups.entries()) {
result.push(
...buildSameFieldFilter(field, toSieveOperator(matchMode), values, filters.value[field].operator)
);
}
}
result.push(
...buildSameFieldFilter(
'Id',
'==',
getConstraintValues('Id').map((value) => escapeFilterValue(value)),
filters.value.Id.operator
)
);
result.push(
...buildSameFieldFilter(
'CountryCode',
'==',
getConstraintValues('CountryCode').map((value) => escapeFilterValue(value)),
filters.value.CountryCode.operator
)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadCountries() {
countries.value = await lookupApi.countries();
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await peopleApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load people.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.firstName = '';
form.lastName = '';
form.firstNameTranscription = '';
form.lastNameTranscription = '';
form.addressLine = '';
form.postalCode = '';
form.city = '';
form.addressCountry = '';
form.email = '';
form.contactPhone = '';
form.birthDate = '';
form.documentNumber = '';
form.countryCode = '';
form.countryName = '';
form.fullNameTranscription = '';
form.registrationsCount = 0;
birthDate.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await peopleApi.get(id);
Object.assign(form, item);
birthDate.value = toDate(item.birthDate);
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the person.';
}
}
async function save() {
error.value = '';
form.birthDate = toDateOnlyString(birthDate.value);
try {
if (form.id > 0) {
await peopleApi.update({ ...form });
toast.add({ severity: 'success', summary: 'People', detail: 'Person updated successfully.', life: 3000 });
} else {
await peopleApi.create({ ...form });
toast.add({ severity: 'success', summary: 'People', detail: 'Person created successfully.', life: 3000 });
}
touchPeopleCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'People', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this person?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await peopleApi.remove(id);
touchPeopleCatalog();
toast.add({ severity: 'success', summary: 'People', detail: 'Person deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'People', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onSearch() {
page.value = 1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(async () => {
await loadCountries();
await loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
showCurrentPageReport
:rows="pageSize"
:rowsPerPageOptions="[10, 25, 50, 100]"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
currentPageReportTemplate="{first} to {last} of {totalRecords}"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable sortField="Id" filterField="Id" :showFilterMatchModes="false" :showAddButton="false" :filterMenuStyle="{ width: '12rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="number" inputmode="numeric" placeholder="ID" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstName" header="First name" sortable sortField="FirstName" filterField="FirstName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastName" header="Last name" sortable sortField="LastName" filterField="LastName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="email" header="Email" sortable sortField="Email" filterField="Email" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by email" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastNameTranscription" header="Last name transcription" sortable sortField="LastNameTranscription" filterField="LastNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstNameTranscription" header="First name transcription" sortable sortField="FirstNameTranscription" filterField="FirstNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="countryName" header="Country" sortable sortField="CountryName" filterField="CountryCode" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="All countries"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="birthDate" header="Birth date" sortable>
<template #body="{ data }">
{{ formatDateOnly(data.birthDate) }}
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount" />
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '78rem' }" :header="form.id ? 'Edit person' : 'New person'">
<div class="field-grid field-grid-person">
<div class="field"><label>First name</label><InputText v-model="form.firstName" /></div>
<div class="field"><label>Last name</label><InputText v-model="form.lastName" /></div>
<div class="field"><label>First name transcription</label><InputText v-model="form.firstNameTranscription" /></div>
<div class="field"><label>Last name transcription</label><InputText v-model="form.lastNameTranscription" /></div>
<div class="field"><label>Address</label><InputText v-model="form.addressLine" /></div>
<div class="field"><label>Postal code</label><InputText v-model="form.postalCode" /></div>
<div class="field"><label>City</label><InputText v-model="form.city" /></div>
<div class="field"><label>Address country</label><InputText v-model="form.addressCountry" /></div>
<div class="field"><label>Email</label><InputText v-model="form.email" /></div>
<div class="field"><label>Phone</label><InputText v-model="form.contactPhone" /></div>
<div class="field"><label>Birth date</label><DatePicker v-model="birthDate" date-format="dd.mm.yy" show-icon fluid /></div>
<div class="field"><label>Document number</label><InputText v-model="form.documentNumber" /></div>
<div class="field">
<label>Person country</label>
<Select
v-model="form.countryCode"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="Select country"
filter
/>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,527 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import AutoComplete, { type AutoCompleteCompleteEvent } from 'primevue/autocomplete';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { eventsApi, lookupApi, registrationsApi, sportsApi } from '../api/eventsApi';
import { eventsCatalogVersion, peopleCatalogVersion, sportsCatalogVersion, touchEventsCatalog } from '../state/catalogState';
import { activeTab, consumePendingRegistrationEventId, pendingRegistrationEventId } from '../state/uiState';
import type { EventDto, IdName, RegistrationDto, RegistrationUpsertDto, SportDto } from '../api/types';
import { formatDateTime } from '../utils/dates';
const rows = ref<RegistrationDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const selectedPerson = ref<IdName<number> | null>(null);
const peopleSuggestions = ref<Array<IdName<number>>>([]);
const countries = ref<Array<IdName<string>>>([]);
const sports = ref<SportDto[]>([]);
const events = ref<EventDto[]>([]);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('RegisteredAt');
const sortOrder = ref<1 | -1>(-1);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<RegistrationDto>({
id: 0,
eventId: 0,
personId: 0,
sportId: 0,
registeredAt: null,
personName: '',
personTranscription: '',
personFirstNameTranscription: '',
personLastNameTranscription: '',
countryCode: '',
countryName: '',
sportName: ''
});
const eventOptions = computed(() =>
events.value.map((item) => ({
label: `${item.name} (${item.eventDate})`,
value: item.id
}))
);
const sportOptions = computed(() =>
sports.value.map((item) => ({
label: item.name,
value: item.id
}))
);
const countryOptions = computed(() =>
countries.value.map((item) => ({
label: item.name,
value: item.id
}))
);
const filters = ref({
EventId: {
operator: 'and',
constraints: [{ value: null as number | null, matchMode: 'equals' }]
},
PersonName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
PersonLastNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
PersonFirstNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
CountryCode: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
},
SportName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function getConstraintValues(field: keyof typeof filters.value) {
return filters.value[field].constraints
.map((constraint) => (constraint.value ?? '').toString().trim())
.filter((value) => value !== '');
}
function buildSameFieldFilter(field: string, operator: string, values: string[], logicalOperator: string) {
if (values.length === 0) {
return [];
}
if (values.length === 1) {
return [`${field}${operator}${values[0]}`];
}
if (logicalOperator === 'or') {
return [`${field}${operator}${values.join('|')}`];
}
return values.map((value) => `${field}${operator}${value}`);
}
function toRegistrationPayload(): RegistrationUpsertDto {
return {
id: form.id,
eventId: form.eventId,
personId: form.personId,
sportId: form.sportId
};
}
function buildFilters() {
const result: string[] = [];
const textFields = ['PersonName', 'PersonLastNameTranscription', 'PersonFirstNameTranscription', 'SportName'] as const;
for (const field of textFields) {
const groups = new Map<string, string[]>();
for (const constraint of filters.value[field].constraints) {
const value = (constraint.value ?? '').toString().trim();
if (!value) {
continue;
}
const key = constraint.matchMode ?? 'contains';
const entries = groups.get(key) ?? [];
entries.push(escapeFilterValue(value));
groups.set(key, entries);
}
for (const [matchMode, values] of groups.entries()) {
result.push(
...buildSameFieldFilter(field, toSieveOperator(matchMode), values, filters.value[field].operator)
);
}
}
result.push(
...buildSameFieldFilter(
'EventId',
'==',
getConstraintValues('EventId').map((value) => escapeFilterValue(value)),
filters.value.EventId.operator
)
);
result.push(
...buildSameFieldFilter(
'CountryCode',
'==',
getConstraintValues('CountryCode').map((value) => escapeFilterValue(value)),
filters.value.CountryCode.operator
)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadAuxiliaryData() {
const [loadedEvents, loadedSports, loadedCountries] = await Promise.all([
eventsApi.list({ page: 1, pageSize: 500, sort: 'EventDate', sortOrder: 1 }),
sportsApi.list({ page: 1, pageSize: 500, sort: 'Name', sortOrder: 1 }),
lookupApi.countries()
]);
events.value = loadedEvents.data ?? [];
sports.value = loadedSports.data ?? [];
countries.value = loadedCountries;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await registrationsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load registrations.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.eventId = 0;
form.personId = 0;
form.sportId = 0;
form.registeredAt = null;
form.personName = '';
form.personTranscription = '';
form.personFirstNameTranscription = '';
form.personLastNameTranscription = '';
form.countryCode = '';
form.countryName = '';
form.sportName = '';
selectedPerson.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
function openCreateForEvent(eventId: number) {
error.value = '';
resetForm();
form.eventId = eventId;
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await registrationsApi.get(id);
Object.assign(form, item);
selectedPerson.value = { id: item.personId, name: item.personName, description: item.personTranscription };
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the registration.';
}
}
async function completePeopleLookup(event: AutoCompleteCompleteEvent) {
const query = event.query?.trim() ?? '';
if (!query) {
peopleSuggestions.value = [];
return;
}
try {
const countryCode = filters.value.CountryCode.constraints[0].value || undefined;
peopleSuggestions.value = await lookupApi.people(query, countryCode);
} catch (err) {
peopleSuggestions.value = [];
error.value = err instanceof Error ? err.message : 'Unable to load people for lookup.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
async function save() {
error.value = '';
form.personId = selectedPerson.value?.id ?? 0;
try {
if (form.id > 0) {
await registrationsApi.update(toRegistrationPayload());
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration updated successfully.', life: 3000 });
} else {
await registrationsApi.create(toRegistrationPayload());
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration created successfully.', life: 3000 });
}
touchEventsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this registration?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await registrationsApi.remove(id);
touchEventsCatalog();
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
});
}
async function downloadCertificate(id: number) {
error.value = '';
try {
const file = await registrationsApi.downloadCertificate(id);
const url = URL.createObjectURL(file.blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Certificate download failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
watch([eventsCatalogVersion, sportsCatalogVersion, peopleCatalogVersion], async () => {
await loadAuxiliaryData();
});
watch([activeTab, pendingRegistrationEventId], async () => {
if (activeTab.value !== 'registrations' || pendingRegistrationEventId.value === null) {
return;
}
if (events.value.length === 0) {
await loadAuxiliaryData();
}
const eventId = consumePendingRegistrationEventId();
if (eventId !== null) {
openCreateForEvent(eventId);
}
});
defineExpose({
openCreate
});
onMounted(async () => {
await loadAuxiliaryData();
await loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
showCurrentPageReport
:rows="pageSize"
:rowsPerPageOptions="[10, 25, 50, 100]"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
currentPageReportTemplate="{first} to {last} of {totalRecords}"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="personName" header="Person" sortable sortField="PersonName" filterField="PersonName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by person" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="personLastNameTranscription" header="Last name transcription" sortable sortField="PersonLastNameTranscription" filterField="PersonLastNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="personFirstNameTranscription" header="First name transcription" sortable sortField="PersonFirstNameTranscription" filterField="PersonFirstNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="countryName" header="Country" sortable filterField="CountryCode" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="All countries"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="sportName" header="Sport" sortable sortField="SportName" filterField="SportName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by sport" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="eventId" header="Event" sortable sortField="EventId" filterField="EventId" :showFilterMatchModes="false" :filterMenuStyle="{ width: '16rem' }">
<template #body="{ data }">
{{ events.find((item) => item.id === data.eventId)?.name || data.eventId }}
</template>
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="eventOptions"
option-label="label"
option-value="value"
placeholder="All events"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="registeredAt" header="Registered at" sortable>
<template #body="{ data }">
{{ formatDateTime(data.registeredAt) }}
</template>
</Column>
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-download" text rounded @click="downloadCertificate(data.id)" />
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '68rem' }" :header="form.id ? 'Edit registration' : 'New registration'">
<div class="field-grid field-grid-registration">
<div class="field field-span-2">
<label>Event</label>
<Select v-model="form.eventId" :options="eventOptions" option-label="label" option-value="value" />
</div>
<div class="field">
<label>Sport</label>
<Select v-model="form.sportId" :options="sportOptions" option-label="label" option-value="value" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label>Person</label>
<AutoComplete
v-model="selectedPerson"
:suggestions="peopleSuggestions"
optionLabel="name"
:minLength="1"
completeOnFocus
fluid
showClear
force-selection
@complete="completePeopleLookup"
>
<template #option="{ option }">
<div class="lookup-item">
<span>{{ option.name }}</span>
<small>{{ option.description || `ID: ${option.id}` }}</small>
</div>
</template>
</AutoComplete>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import { sportsApi } from '../api/eventsApi';
import { touchSportsCatalog } from '../state/catalogState';
import type { SportDto } from '../api/types';
const rows = ref<SportDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('Id');
const sortOrder = ref<1 | -1>(1);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<SportDto>({
id: 0,
name: ''
});
const filters = ref({
Name: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function buildFilters() {
const result = filters.value.Name.constraints
.filter((constraint) => (constraint.value ?? '').trim() !== '')
.map((constraint) => `Name${toSieveOperator(constraint.matchMode)}${escapeFilterValue((constraint.value ?? '').trim())}`);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await sportsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load sports.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.name = '';
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await sportsApi.get(id);
form.id = item.id;
form.name = item.name;
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the sport.';
}
}
async function save() {
error.value = '';
try {
if (form.id > 0) {
await sportsApi.update({ ...form });
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport updated successfully.', life: 3000 });
} else {
await sportsApi.create({ ...form });
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport created successfully.', life: 3000 });
}
touchSportsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Sports', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this sport?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await sportsApi.remove(id);
touchSportsCatalog();
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Sports', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(() => {
void loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
showCurrentPageReport
:rows="pageSize"
:rowsPerPageOptions="[10, 25, 50, 100]"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
currentPageReportTemplate="{first} to {last} of {totalRecords}"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '42rem' }" :header="form.id ? 'Edit sport' : 'New sport'">
<div class="field">
<label for="sport-name">Name</label>
<InputText id="sport-name" v-model="form.name" />
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import ConfirmationService from 'primevue/confirmationservice';
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import Aura from '@primeuix/themes/aura';
import App from './App.vue';
import { auth0 } from './auth';
import 'primeicons/primeicons.css';
import './style.css';
const app = createApp(App);
app.use(PrimeVue, {
theme: {
preset: Aura
}
});
app.use(auth0);
app.use(ConfirmationService);
app.use(ToastService);
app.mount('#app');

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue';
export const eventsCatalogVersion = ref(0);
export const sportsCatalogVersion = ref(0);
export const peopleCatalogVersion = ref(0);
export function touchEventsCatalog() {
eventsCatalogVersion.value += 1;
}
export function touchSportsCatalog() {
sportsCatalogVersion.value += 1;
}
export function touchPeopleCatalog() {
peopleCatalogVersion.value += 1;
}

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue';
export const activeTab = ref('sports');
export const pendingRegistrationEventId = ref<number | null>(null);
export function openRegistrationCreateForEvent(eventId: number) {
pendingRegistrationEventId.value = eventId;
activeTab.value = 'registrations';
}
export function consumePendingRegistrationEventId() {
const eventId = pendingRegistrationEventId.value;
pendingRegistrationEventId.value = null;
return eventId;
}

View File

@@ -0,0 +1,246 @@
:root {
color-scheme: light;
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
background:
radial-gradient(circle at top left, rgba(26, 115, 232, 0.15), transparent 30%),
radial-gradient(circle at bottom right, rgba(28, 180, 137, 0.14), transparent 28%),
#f4f7fb;
color: #16324f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
#app {
min-height: 100vh;
}
.app-shell {
max-width: 1600px;
margin: 0 auto;
padding: 18px;
}
.workspace {
padding: 18px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(14px);
box-shadow: 0 20px 40px rgba(14, 42, 71, 0.08);
}
.panel-card {
border-radius: 20px;
padding: 18px;
background: #ffffff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 8px 24px rgba(15, 35, 56, 0.06);
}
.panel-card .p-paginator {
position: relative;
justify-content: center;
}
.panel-card .p-paginator-current {
position: absolute;
left: 0;
}
@media (max-width: 768px) {
.panel-card .p-paginator {
justify-content: flex-start;
row-gap: 0.5rem;
padding-top: 2rem;
}
.panel-card .p-paginator-current {
top: 0.25rem;
}
}
.tabs-header-bar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.tabs-header-bar > :first-child {
justify-self: start;
}
.tabs-header-center {
justify-self: center;
}
.tabs-header-actions {
display: flex;
align-items: center;
gap: 12px;
justify-self: end;
}
.user-chip {
padding: 0.6rem 0.9rem;
border-radius: 999px;
background: rgba(22, 50, 79, 0.08);
color: #35536d;
font-size: 0.92rem;
white-space: nowrap;
}
.user-chip-button {
border: 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.panel-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.field-grid-person {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.field-grid-registration {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-span-2 {
grid-column: span 2;
}
.field label {
font-size: 0.88rem;
font-weight: 600;
color: #35536d;
}
.column-header-filter {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 10rem;
}
.column-header-filter > span {
font-weight: 600;
}
.inline-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.status-banner {
margin-top: 16px;
border-radius: 16px;
padding: 12px 14px;
font-size: 0.95rem;
}
.status-banner.error {
background: #fff0f0;
color: #a61b1b;
}
.status-banner.success {
background: #eefbf4;
color: #12663f;
}
.lookup-item {
display: flex;
flex-direction: column;
}
.lookup-item small {
color: #65819b;
}
.auth-state {
min-height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.auth-card {
width: min(28rem, 100%);
text-align: center;
}
.auth-card h1 {
margin-top: 0;
margin-bottom: 0.75rem;
}
.auth-card p {
margin-bottom: 1.25rem;
color: #4d6780;
}
@media (max-width: 768px) {
.app-shell {
padding: 16px;
}
.tabs-header-bar {
grid-template-columns: 1fr;
}
.tabs-header-center,
.tabs-header-actions,
.tabs-header-bar > :first-child {
justify-self: stretch;
}
.tabs-header-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.field-grid-person,
.field-grid-registration {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
}

View File

@@ -0,0 +1,38 @@
export function toDate(value?: string | null) {
if (!value) {
return null;
}
const [year, month, day] = value.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
export function toDateOnlyString(value: Date | null | undefined) {
if (!value) {
return '';
}
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, '0');
const day = `${value.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function formatDateOnly(value?: string | null) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('hr-HR').format(toDate(value) ?? new Date(value));
}
export function formatDateTime(value?: string | null) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('hr-HR', {
dateStyle: 'short',
timeStyle: 'short'
}).format(new Date(value));
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
});

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<UserSecretsId>PI</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LargeXlsx" Version="2.0.1" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.5.9" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Events.Auth\Events.Auth.csproj" />
<ProjectReference Include="..\Events.WebAPI.Contract\Events.WebAPI.Contract.csproj" />
<ProjectReference Include="..\Events.WebAPI.Handlers.EF\Events.WebAPI.Handlers.EF.csproj" />
</ItemGroup>
</Project>

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,33 @@
using Microsoft.AspNetCore.Mvc;
using Events.Auth;
using Events.FilesAPI.Features.Certificates.Download;
using MediatR;
using Microsoft.AspNetCore.Authorization;
namespace Events.FilesAPI.Features.Certificates;
[ApiController]
[Authorize(Policy = nameof(Policies.ReadData))]
[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);
}
}

View File

@@ -0,0 +1,33 @@
using Events.FilesAPI.Infrastructure.Files;
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Microsoft.EntityFrameworkCore;
using MediatR;
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
public sealed class DownloadRegistrationsExcelHandler(
EventsContext context,
RegistrationsExcelFileGenerator generator,
RegistrationsExcelFileLocator fileLocator) : IRequestHandler<DownloadRegistrationsExcelQuery, DownloadRegistrationsExcelResult>
{
public async Task<DownloadRegistrationsExcelResult> Handle(DownloadRegistrationsExcelQuery request, CancellationToken cancellationToken)
{
bool exists = await context.Events
.AsNoTracking()
.AnyAsync(e => e.Id == request.EventId, cancellationToken);
if (!exists)
return new DownloadRegistrationsExcelResult(false, null);
GeneratedFileReference? file = fileLocator.TryGet(request.EventId);
if (file == null)
{
await generator.GenerateAsync(request.EventId, cancellationToken);
file = fileLocator.TryGet(request.EventId);
}
return new DownloadRegistrationsExcelResult(true, file);
}
}

View File

@@ -0,0 +1,8 @@
using Events.FilesAPI.Infrastructure.Files;
using MediatR;
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
public sealed record DownloadRegistrationsExcelQuery(int EventId) : IRequest<DownloadRegistrationsExcelResult>;
public sealed record DownloadRegistrationsExcelResult(bool EventFound, GeneratedFileReference? File);

View File

@@ -0,0 +1,27 @@
using Events.FilesAPI.Infrastructure.Files;
using Events.FilesAPI.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
public sealed class RegistrationsExcelFileLocator(
IHostEnvironment hostEnvironment,
IOptions<GeneratedFilesOptions> generatedFilesOptions)
{
public GeneratedFileReference? TryGet(int eventId)
{
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
? generatedFilesOptions.Value.OutputPath
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
string excelPath = Path.Combine(rootPath, $"{eventId}.xlsx");
if (!File.Exists(excelPath))
return null;
return new GeneratedFileReference
{
FileName = Path.GetFileName(excelPath),
PhysicalPath = excelPath
};
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
using Events.Auth;
using Events.FilesAPI.Features.RegistrationsExcel.Download;
using MediatR;
using Microsoft.AspNetCore.Authorization;
namespace Events.FilesAPI.Features.RegistrationsExcel;
[ApiController]
[Authorize(Policy = nameof(Policies.ReadData))]
[Route("Events")]
public class DownloadRegistrationsExcelController : ControllerBase
{
private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
[HttpGet("{id}/RegistrationsExcel")]
[ProducesResponseType(typeof(PhysicalFileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadRegistrationsExcel(
int id,
[FromServices] IMediator mediator,
CancellationToken cancellationToken)
{
DownloadRegistrationsExcelResult result = await mediator.Send(new DownloadRegistrationsExcelQuery(id), cancellationToken);
if (!result.EventFound)
return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}");
if (result.File == null)
return Problem(statusCode: StatusCodes.Status404NotFound, detail: "Registrations Excel could not be generated.");
return PhysicalFile(result.File.PhysicalPath, XlsxContentType, result.File.FileName);
}
}

View File

@@ -0,0 +1,102 @@
using LargeXlsx;
using Microsoft.EntityFrameworkCore;
namespace Events.FilesAPI.Features.RegistrationsExcel;
internal static class EventRegistrationsExcelWriter
{
public static async Task WriteAsync(
string path,
IQueryable<RowData> rows,
RowData firstRow,
CancellationToken cancellationToken)
{
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
await using var writer = new XlsxWriter(stream);
string[] headers =
[
"Registration ID",
"Registration date",
"Person ID",
"Last name",
"First name",
"Last name transcription",
"First name transcription",
"Country",
"Sport"
];
string[] firstRowValues = GetRowValues(firstRow);
var columns = headers
.Select((header, index) => XlsxColumn.Formatted(GetWidth(header, firstRowValues[index])))
.ToArray();
writer
.BeginWorksheet("Registrations", columns: columns)
.BeginRow();
foreach (string header in headers)
{
writer.Write(header);
}
await foreach (RowData row in rows.AsAsyncEnumerable().WithCancellation(cancellationToken))
{
writer
.BeginRow()
.Write(row.RegistrationId)
.Write(FormatRegisteredAt(row.RegisteredAt))
.Write(row.PersonId)
.Write(row.LastName)
.Write(row.FirstName)
.Write(row.LastNameTranscription)
.Write(row.FirstNameTranscription)
.Write(row.CountryName)
.Write(row.SportName);
}
await writer.CommitAsync();
}
private static string[] GetRowValues(RowData row)
{
return
[
row.RegistrationId.ToString(),
FormatRegisteredAt(row.RegisteredAt),
row.PersonId.ToString(),
row.LastName,
row.FirstName,
row.LastNameTranscription,
row.FirstNameTranscription,
row.CountryName,
row.SportName
];
}
private static double GetWidth(string header, string sample)
{
int maxLength = Math.Max(header.Length, sample.Length);
double paddedWidth = Math.Ceiling(maxLength * 1.25d + 4d);
return Math.Clamp(paddedWidth, 10d, 60d);
}
private static string FormatRegisteredAt(DateTime? value)
{
return value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm:ss") : string.Empty;
}
internal sealed class RowData
{
public int RegistrationId { get; init; }
public DateTime? RegisteredAt { 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 CountryName { get; init; } = string.Empty;
public string SportName { get; init; } = string.Empty;
}
}

View File

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

View File

@@ -0,0 +1,70 @@
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.RegistrationsExcel.Synchronize;
public sealed class RegistrationsExcelFileGenerator(
EventsContext context,
IHostEnvironment hostEnvironment,
IOptions<GeneratedFilesOptions> generatedFilesOptions,
ILogger<RegistrationsExcelFileGenerator> logger)
{
public async Task GenerateAsync(int eventId, CancellationToken cancellationToken)
{
var registrations = context.Set<Registration>()
.AsNoTracking()
.Where(r => r.EventId == eventId)
.OrderBy(r => r.Person.LastName)
.ThenBy(r => r.Person.FirstName)
.ThenBy(r => r.Sport.Name)
.Select(r => new EventRegistrationsExcelWriter.RowData
{
RegistrationId = r.Id,
RegisteredAt = r.RegisteredAt,
PersonId = r.PersonId,
FirstName = r.Person.FirstName,
LastName = r.Person.LastName,
FirstNameTranscription = r.Person.FirstNameTranscription,
LastNameTranscription = r.Person.LastNameTranscription,
CountryName = r.Person.CountryCodeNavigation.Name,
SportName = r.Sport.Name
});
string excelPath = GetPath(eventId);
EventRegistrationsExcelWriter.RowData? firstRow = await registrations.FirstOrDefaultAsync(cancellationToken);
if (firstRow == null)
{
DeleteFileIfExists(excelPath);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(excelPath)!);
await EventRegistrationsExcelWriter.WriteAsync(excelPath, registrations, firstRow, cancellationToken);
logger.LogInformation(
"Event registrations Excel generated for event #{EventId} at {Path}",
eventId,
excelPath);
}
private string GetPath(int eventId)
{
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
? generatedFilesOptions.Value.OutputPath
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
return Path.Combine(rootPath, $"{eventId}.xlsx");
}
private void DeleteFileIfExists(string path)
{
if (!File.Exists(path))
return;
File.Delete(path);
logger.LogInformation("Event registrations Excel deleted at {Path}", path);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace Events.FilesAPI.Infrastructure.Files;
public sealed class GeneratedFileReference
{
public string PhysicalPath { get; init; } = string.Empty;
public string FileName { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,45 @@
using Events.FilesAPI.Features.Certificates;
using Events.FilesAPI.Features.RegistrationsExcel;
using MassTransit;
using Microsoft.Extensions.Options;
namespace Events.FilesAPI.Infrastructure.Messaging;
public static class MassTransitSetupExtensions
{
public static void SetupMassTransit(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<RabbitMqSettings>()
.Bind(configuration.GetSection("RabbitMq"))
.ValidateDataAnnotations()
.Validate(
settings => Uri.TryCreate(settings.Host, UriKind.Absolute, out var uri) &&
uri.Scheme == "rabbitmq" &&
!string.IsNullOrWhiteSpace(uri.Host),
"RabbitMq:Host must be a valid absolute rabbitmq:// URI.")
.ValidateOnStart();
services.AddMassTransit(x =>
{
x.AddConsumer<CertificateRegistrationEventsConsumer>();
x.AddConsumer<RegistrationsExcelEventsConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
var settings = context.GetRequiredService<IOptions<RabbitMqSettings>>().Value;
cfg.Host(new Uri(settings.Host), h =>
{
h.Username(settings.Username);
h.Password(settings.Password);
});
cfg.ReceiveEndpoint("events-filesapi-registration-changes", e =>
{
e.ConfigureConsumer<CertificateRegistrationEventsConsumer>(context);
e.ConfigureConsumer<RegistrationsExcelEventsConsumer>(context);
});
});
});
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Events.FilesAPI.Infrastructure.Messaging;
public class RabbitMqSettings
{
[Required]
public string Host { get; set; } = string.Empty;
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Events.FilesAPI.Infrastructure.Options;
public class GeneratedFilesOptions
{
[Required]
public string OutputPath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,56 @@
using Events.Auth;
using Events.FilesAPI.Features.Certificates.Download;
using Events.FilesAPI.Features.Certificates.Synchronize;
using Events.FilesAPI.Features.RegistrationsExcel.Download;
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
using Events.FilesAPI.Infrastructure.Messaging;
using Events.FilesAPI.Infrastructure.Options;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<EventsContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres")));
builder.Services.AddOptions<GeneratedFilesOptions>()
.Bind(builder.Configuration.GetSection("Paths"))
.ValidateDataAnnotations()
.Validate(
settings => !string.IsNullOrWhiteSpace(settings.OutputPath),
"GeneratedFilesOptions:OutputPath must be configured.")
.ValidateOnStart();
builder.Services.AddTransient<CertificateFileGenerator>();
builder.Services.AddTransient<CertificateFileLocator>();
builder.Services.AddTransient<RegistrationsExcelFileGenerator>();
builder.Services.AddTransient<RegistrationsExcelFileLocator>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.SetupMassTransit(builder.Configuration);
builder.Services.SetupAuthenticationAndAuthorization(
builder.Configuration["Auth:Authority"] ?? throw new InvalidOperationException("Missing configuration value Auth:Authority."),
builder.Configuration["Auth:Audience"] ?? throw new InvalidOperationException("Missing configuration value Auth:Audience."));
var app = builder.Build();
app.UseRouting();
app.UseCors(builder =>
{
builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Token-Expired", "Content-Disposition");
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,14 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7296",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"RabbitMq": {
"Host": "rabbitmq://localhost",
"Username": "guest",
"Password": "guest"
},
"Paths": {
"OutputPath": "./GeneratedFiles"
},
"ConnectionStrings": {
"EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;"
},
"Auth": {
"Authority": "https://fer-web2.eu.auth0.com/",
"Audience": "https://erasmus-sta-2026/events-api"
}
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Events.WebAPI.Contract.Command;
public class AddCommand<TDto, TPK>(TDto dto) : IRequest<TPK>
{
public TDto Dto { get; set; } = dto;
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace MobilityOne.Common.Commands;
public class DeleteCommand<TDto, TPK>(TPK id) : IRequest
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Events.WebAPI.Contract.Command;
public class UpdateCommand<TDto>(TDto dto) : IRequest
{
public TDto Dto { get; set; } = dto;
}

View File

@@ -0,0 +1,18 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class EventDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string Name { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public DateOnly EventDate { get; set; }
[Sieve(CanSort = true)]
public int RegistrationsCount { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Events.WebAPI.Contract.DTOs;
public interface IHasIdAsPK<T> where T : IEquatable<T>
{
T Id { get; }
}

View File

@@ -0,0 +1,10 @@
namespace Events.WebAPI.Contract.DTOs;
public class IdName<T>
{
public T Id { get; set; } = default!;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Events.WebAPI.Contract.DTOs;
/// <summary>
/// Contains requested items (based on filter, paging and sorting criteria),
/// and the number of total items satisfying the filter
/// (or count of all items if no filter is present)
/// </summary>
/// <typeparam name="T"></typeparam>
public class Items<T>
{
public List<T>? Data { get; set; }
public int Count { get; set; }
}

View File

@@ -0,0 +1,52 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class PersonDTO : IHasIdAsPK<int>
{
[Sieve(CanFilter = true, CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string FirstName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string LastName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string FirstNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string LastNameTranscription { get; set; } = string.Empty;
public string AddressLine { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string AddressCountry { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string Email { get; set; } = string.Empty;
public string ContactPhone { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public DateOnly BirthDate { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string DocumentNumber { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string CountryCode { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string CountryName { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public string FullNameTranscription { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public int RegistrationsCount { get; set; }
}

View File

@@ -0,0 +1,42 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class RegistrationDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int EventId { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int PersonId { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int SportId { get; set; }
[Sieve(CanSort = true)]
public DateTime RegisteredAt { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string PersonName { get; set; } = string.Empty;
[Sieve(CanFilter = true)]
public string PersonTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string PersonFirstNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string PersonLastNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true)]
public string CountryCode { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public string CountryName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string SportName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class SportDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string Name { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Sieve" Version="2.5.5" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.LookupQueries;
public class LookupCountryQuery : IRequest<List<IdName<string>>>
{
public string? Text { get; set; }
}

View File

@@ -0,0 +1,11 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.LookupQueries;
public class LookupPeopleQuery : IRequest<List<IdName<int>>>
{
public string? Text { get; set; }
public string? CountryCode { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationCreated
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationDeleted
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationUpdated
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
public int PreviousPersonId { get; init; }
public int PreviousEventId { get; init; }
public int PreviousSportId { get; init; }
}

View File

@@ -0,0 +1,10 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class DoesItemExistsQuery<TDto, TPK> (TPK id) : IRequest<bool>, IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public abstract class GetCountQuery : IRequest<int>
{
public string? Filters { get; set; }
}
public class GetCountQuery<TDto> : GetCountQuery
{
public static GetCountQuery<TDto> CreateForPK<TPK>(TPK id) where TPK : IEquatable<TPK> {
var query = new GetCountQuery<TDto>()
{
Filters = $"id=={id}"
};
return query;
}
}

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class GetItemsQuery<TDto> : IRequest<List<TDto>>
{
public string? Filters { get; set; }
public string? Sort { get; set; }
public bool Ascending { get; set; }
public int? PageSize { get; set; }
public int? Page { get; set; }
}

View File

@@ -0,0 +1,10 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class GetSingleItemQuery<TDto, TPK>(TPK id) : IRequest<TDto>, IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
namespace Events.WebAPI.Contract.Validation.Event;
public class AddEventValidator : AbstractValidator<AddCommand<EventDTO, int>>
{
public AddEventValidator()
{
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
RuleFor(a => a.Dto.EventDate).NotEmpty();
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Event;
public class DeleteEventValidator : AbstractValidator<DeleteCommand<EventDTO, int>>
{
public DeleteEventValidator(IMediator mediator)
{
RuleFor(a => a.Id).NoChildRecords<DeleteCommand<EventDTO, int>, RegistrationDTO, int>(nameof(RegistrationDTO.EventId), mediator);
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
namespace Events.WebAPI.Contract.Validation.Event;
public class UpdateEventValidator : AbstractValidator<UpdateCommand<EventDTO>>
{
public UpdateEventValidator()
{
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
RuleFor(a => a.Dto.EventDate).NotEmpty();
}
}

View File

@@ -0,0 +1,49 @@
using FluentValidation;
using MediatR;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Queries.Generic;
namespace Events.WebAPI.Contract.Validation;
public static class ForeignKeyValueValidatorExtension
{
public static IRuleBuilderOptions<TCommand, TPK> ForeignKeyExists<TCommand, TDto, TPK>(
this IRuleBuilder<TCommand, TPK> ruleBuilder,
IMediator mediator,
IValidationMessageProvider validationMessageProvider,
ValidationMessage? validationMessage = null)
where TDto : IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
ValidationMessage message = validationMessage ?? validationMessageProvider.ForeignKeyNotFound("{PropertyName}");
return ruleBuilder.MustAsync(new ForeignKeyValueValidator<TCommand, TDto, TPK>(mediator).Validate)
.WithMessage(message.Message)
.WithErrorCode(message.Code);
}
private class ForeignKeyValueValidator<TCommand, TDto, TPK> where TDto : IHasIdAsPK<TPK> where TPK : IEquatable<TPK>
{
private readonly IMediator mediator;
public ForeignKeyValueValidator(IMediator mediator)
{
this.mediator = mediator;
}
public async Task<bool> Validate(TCommand command, TPK value, ValidationContext<TCommand> validationContext, CancellationToken cancellationToken)
{
var query = new DoesItemExistsQuery<TDto, TPK>(value);
try
{
bool itemExists = await mediator.Send(query, cancellationToken);
return itemExists;
}
catch (Exception exc)
{
validationContext.AddFailure(exc.Message);
return false;
}
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Events.WebAPI.Contract.Validation;
public interface IValidationMessageProvider
{
ValidationMessage UniqueSportName(string sportName);
ValidationMessage UniquePersonDocumentAndCountry();
ValidationMessage PersonEmailOrContactPhoneRequired();
ValidationMessage UniqueRegistration();
ValidationMessage EventNotFound();
ValidationMessage PersonNotFound();
ValidationMessage SportNotFound();
ValidationMessage ForeignKeyNotFound(string propertyName);
}

View File

@@ -0,0 +1,36 @@
using Events.WebAPI.Contract.Queries.Generic;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public static class NoChildRecordsValidatorExtension
{
public static IRuleBuilderOptions<TCommand, TPK> NoChildRecords<TCommand, TDto, TPK>(this IRuleBuilder<TCommand, TPK> ruleBuilder, string columnName, IMediator mediator)
{
return ruleBuilder.MustAsync(new NoChildRecordsValidator<TDto, TPK>(columnName, mediator).Validate)
.WithMessage("Cannot delete entity {PropertyValue} because there are child records in table related to " + typeof(TDto).Name.ToString());
}
private class NoChildRecordsValidator<TDto, TPK>
{
private readonly string columnName;
private readonly IMediator mediator;
public NoChildRecordsValidator(string columnName, IMediator mediator)
{
this.columnName = columnName;
this.mediator = mediator;
}
public async Task<bool> Validate(TPK value, CancellationToken cancellationToken)
{
var query = new GetCountQuery<TDto>()
{
Filters = $"{columnName}=={value}"
};
int count = await mediator.Send(query, cancellationToken);
return count == 0;
}
}
}

Some files were not shown because too many files have changed in this diff Show More