Compare commits
9 Commits
d06433e2f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fedb24e41c | ||
|
|
80dd9aff39 | ||
|
|
6ad33472e9 | ||
|
|
3e0fc96c0e | ||
|
|
3f2e199ec4 | ||
|
|
b66d05c298 | ||
|
|
4fb3de19f6 | ||
|
|
8f7c704a90 | ||
|
|
b849c6feb6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
Events-WebApi/Events-WebApi.slnx
Normal file
7
Events-WebApi/Events-WebApi.slnx
Normal 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>
|
||||||
54
Events-WebApi/Events.Auth/AuthSetupExtensions.cs
Normal file
54
Events-WebApi/Events.Auth/AuthSetupExtensions.cs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Events-WebApi/Events.Auth/Events.Auth.csproj
Normal file
17
Events-WebApi/Events.Auth/Events.Auth.csproj
Normal 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>
|
||||||
21
Events-WebApi/Events.Auth/Policies.cs
Normal file
21
Events-WebApi/Events.Auth/Policies.cs
Normal 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");
|
||||||
|
}
|
||||||
47
Events-WebApi/Events.Auth/ScopeClaimsTransformation.cs
Normal file
47
Events-WebApi/Events.Auth/ScopeClaimsTransformation.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Events-WebApi/Events.ClientApp/.env.example
Normal file
6
Events-WebApi/Events.ClientApp/.env.example
Normal 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
|
||||||
126
Events-WebApi/Events.ClientApp/README.md
Normal file
126
Events-WebApi/Events.ClientApp/README.md
Normal 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
|
||||||
12
Events-WebApi/Events.ClientApp/index.html
Normal file
12
Events-WebApi/Events.ClientApp/index.html
Normal 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>
|
||||||
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Events-WebApi/Events.ClientApp/package.json
Normal file
25
Events-WebApi/Events.ClientApp/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal file
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal 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>
|
||||||
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal file
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal 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 })
|
||||||
|
};
|
||||||
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal file
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal file
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
20
Events-WebApi/Events.ClientApp/src/auth.ts
Normal file
20
Events-WebApi/Events.ClientApp/src/auth.ts
Normal 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
|
||||||
|
});
|
||||||
323
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal file
323
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal 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>
|
||||||
435
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal file
435
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
231
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal file
231
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal 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>
|
||||||
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal file
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal 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');
|
||||||
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal file
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal 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;
|
||||||
|
}
|
||||||
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal file
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal 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;
|
||||||
|
}
|
||||||
246
Events-WebApi/Events.ClientApp/src/style.css
Normal file
246
Events-WebApi/Events.ClientApp/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal file
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal 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));
|
||||||
|
}
|
||||||
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal file
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
28
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal file
28
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal 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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
|
||||||
|
public sealed record SynchronizeCertificateCommand(int EventId, int PersonId) : IRequest;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
|
||||||
|
public sealed record SynchronizeRegistrationsExcelCommand(int EventId) : IRequest;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Infrastructure.Options;
|
||||||
|
|
||||||
|
public class GeneratedFilesOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string OutputPath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
56
Events-WebApi/Events.FilesAPI/Program.cs
Normal file
56
Events-WebApi/Events.FilesAPI/Program.cs
Normal 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();
|
||||||
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal file
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Events-WebApi/Events.FilesAPI/appsettings.json
Normal file
24
Events-WebApi/Events.FilesAPI/appsettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace MobilityOne.Common.Commands;
|
||||||
|
|
||||||
|
public class DeleteCommand<TDto, TPK>(TPK id) : IRequest
|
||||||
|
{
|
||||||
|
public TPK Id { get; set; } = id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Command;
|
||||||
|
|
||||||
|
public class UpdateCommand<TDto>(TDto dto) : IRequest
|
||||||
|
{
|
||||||
|
public TDto Dto { get; set; } = dto;
|
||||||
|
}
|
||||||
18
Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
Normal file
18
Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
Normal 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; }
|
||||||
|
}
|
||||||
6
Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
Normal file
6
Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public interface IHasIdAsPK<T> where T : IEquatable<T>
|
||||||
|
{
|
||||||
|
T Id { get; }
|
||||||
|
}
|
||||||
10
Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
Normal file
10
Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
Normal 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; }
|
||||||
|
}
|
||||||
13
Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
Normal file
13
Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
Normal 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; }
|
||||||
|
}
|
||||||
52
Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
Normal file
52
Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
Normal 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; }
|
||||||
|
}
|
||||||
42
Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
Normal file
42
Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
Normal 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;
|
||||||
|
}
|
||||||
12
Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
Normal file
12
Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user