WebApi + ClientApp, GraphQL, Reflection

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

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
# Events.ClientApp
`Events.ClientApp` is the Vue 3 front-end for Topic 2.
It uses:
- Vite
- Vue 3
- PrimeVue
- Auth0 Vue SDK
It is intended as a companion UI for the `Events.WebAPI` backend and demonstrates how the secured API can be consumed from a browser application.
## 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 Topic2\Events.ClientApp\.env.example Topic2\Events.ClientApp\.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 the Web API
If `VITE_API_BASE_URL` is not set, the app falls back to:
- `https://localhost:7150`
## 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:7150
```
## 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 usually needed if the API expects bearer tokens with a specific audience and scopes.
- `VITE_API_BASE_URL` should point to the running `Events.WebAPI` 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 the secured Topic 2 API
- local development against a separately running ASP.NET Core backend

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
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,
authorizationParams
});

View File

@@ -0,0 +1,319 @@
<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
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="eventDate" header="Date" sortable sortField="EventDate" filterField="EventDate" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #body="{ data }">
{{ formatDateOnly(data.eventDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<DatePicker v-model="filterModel.value" date-format="yy-mm-dd" show-icon fluid @date-select="filterCallback()" />
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount">
<template #body="{ data }">
<Button
v-if="(data.registrationsCount ?? 0) > 0"
:label="String(data.registrationsCount ?? 0)"
link
@click="downloadRegistrationsExcel(data.id)"
/>
<span v-else>{{ data.registrationsCount ?? 0 }}</span>
</template>
</Column>
<Column header="" style="width: 16rem">
<template #body="{ data }">
<div class="inline-actions">
<Button label="New registration" link @click="openRegistrationCreate(data.id)" />
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '48rem' }" :header="form.id ? 'Edit event' : 'New event'">
<div class="field-grid">
<div class="field">
<label for="event-name">Name</label>
<InputText id="event-name" v-model="form.name" />
</div>
<div class="field">
<label for="event-date">Date</label>
<DatePicker id="event-date" v-model="eventDate" date-format="dd.mm.yy" show-icon fluid />
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,431 @@
<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
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable sortField="Id" filterField="Id" :showFilterMatchModes="false" :showAddButton="false" :filterMenuStyle="{ width: '12rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="number" inputmode="numeric" placeholder="ID" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstName" header="First name" sortable sortField="FirstName" filterField="FirstName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastName" header="Last name" sortable sortField="LastName" filterField="LastName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="email" header="Email" sortable sortField="Email" filterField="Email" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by email" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastNameTranscription" header="Last name transcription" sortable sortField="LastNameTranscription" filterField="LastNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstNameTranscription" header="First name transcription" sortable sortField="FirstNameTranscription" filterField="FirstNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="countryName" header="Country" sortable sortField="CountryName" filterField="CountryCode" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="All countries"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="birthDate" header="Birth date" sortable>
<template #body="{ data }">
{{ formatDateOnly(data.birthDate) }}
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount" />
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '78rem' }" :header="form.id ? 'Edit person' : 'New person'">
<div class="field-grid field-grid-person">
<div class="field"><label>First name</label><InputText v-model="form.firstName" /></div>
<div class="field"><label>Last name</label><InputText v-model="form.lastName" /></div>
<div class="field"><label>First name transcription</label><InputText v-model="form.firstNameTranscription" /></div>
<div class="field"><label>Last name transcription</label><InputText v-model="form.lastNameTranscription" /></div>
<div class="field"><label>Address</label><InputText v-model="form.addressLine" /></div>
<div class="field"><label>Postal code</label><InputText v-model="form.postalCode" /></div>
<div class="field"><label>City</label><InputText v-model="form.city" /></div>
<div class="field"><label>Address country</label><InputText v-model="form.addressCountry" /></div>
<div class="field"><label>Email</label><InputText v-model="form.email" /></div>
<div class="field"><label>Phone</label><InputText v-model="form.contactPhone" /></div>
<div class="field"><label>Birth date</label><DatePicker v-model="birthDate" date-format="dd.mm.yy" show-icon fluid /></div>
<div class="field"><label>Document number</label><InputText v-model="form.documentNumber" /></div>
<div class="field">
<label>Person country</label>
<Select
v-model="form.countryCode"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="Select country"
filter
/>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,523 @@
<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
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="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
dropdown
dropdownMode="current"
force-selection
@complete="completePeopleLookup"
>
<template #option="{ option }">
<div class="lookup-item">
<span>{{ option.name }}</span>
<small>{{ option.description || `ID: ${option.id}` }}</small>
</div>
</template>
</AutoComplete>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,227 @@
<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
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '42rem' }" :header="form.id ? 'Edit sport' : 'New sport'">
<div class="field">
<label for="sport-name">Name</label>
<InputText id="sport-name" v-model="form.name" />
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,224 @@
: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);
}
.tabs-header-bar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.tabs-header-bar > :first-child {
justify-self: start;
}
.tabs-header-center {
justify-self: center;
}
.tabs-header-actions {
display: flex;
align-items: center;
gap: 12px;
justify-self: end;
}
.user-chip {
padding: 0.6rem 0.9rem;
border-radius: 999px;
background: rgba(22, 50, 79, 0.08);
color: #35536d;
font-size: 0.92rem;
white-space: nowrap;
}
.user-chip-button {
border: 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.panel-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.field-grid-person {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.field-grid-registration {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-span-2 {
grid-column: span 2;
}
.field label {
font-size: 0.88rem;
font-weight: 600;
color: #35536d;
}
.column-header-filter {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 10rem;
}
.column-header-filter > span {
font-weight: 600;
}
.inline-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.status-banner {
margin-top: 16px;
border-radius: 16px;
padding: 12px 14px;
font-size: 0.95rem;
}
.status-banner.error {
background: #fff0f0;
color: #a61b1b;
}
.status-banner.success {
background: #eefbf4;
color: #12663f;
}
.lookup-item {
display: flex;
flex-direction: column;
}
.lookup-item small {
color: #65819b;
}
.auth-state {
min-height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.auth-card {
width: min(28rem, 100%);
text-align: center;
}
.auth-card h1 {
margin-top: 0;
margin-bottom: 0.75rem;
}
.auth-card p {
margin-bottom: 1.25rem;
color: #4d6780;
}
@media (max-width: 768px) {
.app-shell {
padding: 16px;
}
.tabs-header-bar {
grid-template-columns: 1fr;
}
.tabs-header-center,
.tabs-header-actions,
.tabs-header-bar > :first-child {
justify-self: stretch;
}
.tabs-header-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.field-grid-person,
.field-grid-registration {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<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="PdfSharpCore" Version="1.3.67" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Events.WebAPI.Contract\Events.WebAPI.Contract.csproj" />
<ProjectReference Include="..\Events.WebAPI.Handlers.EF\Events.WebAPI.Handlers.EF.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using Events.FilesAPI.Features.Certificates;
using Events.FilesAPI.Features.RegistrationsExcel;
using Events.FilesAPI.Infrastructure.Messaging;
using Events.FilesAPI.Infrastructure.Options;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using MediatR;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<EventsContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB")));
builder.Services.AddOptions<GeneratedFilesOptions>()
.Bind(builder.Configuration.GetSection("Paths"))
.ValidateDataAnnotations()
.Validate(
settings => !string.IsNullOrWhiteSpace(settings.OutputPath),
"GeneratedFilesOptions:OutputPath must be configured.")
.ValidateOnStart();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.SetupMassTransit(builder.Configuration);
var app = builder.Build();
app.MapControllers();
app.Run();

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"RabbitMq": {
"Host": "rabbitmq://localhost",
"Username": "guest",
"Password": "guest"
},
"Paths": {
"OutputPath": "./Certificates"
},
"ConnectionStrings": {
"EventDB": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Validation;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Person;
public class AddPersonValidator : AbstractValidator<AddCommand<PersonDTO, int>>
{
public AddPersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<PersonDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniquePersonDocumentAndCountry(),
t => t.DocumentNumber,
t => t.CountryCode);
RuleFor(a => a.Dto.FirstName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.FirstNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressLine).NotEmpty().MaximumLength(200);
RuleFor(a => a.Dto.PostalCode).NotEmpty().MaximumLength(20);
RuleFor(a => a.Dto.City).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressCountry).NotEmpty().MaximumLength(100);
When(a => !string.IsNullOrWhiteSpace(a.Dto.Email), () =>
{
RuleFor(a => a.Dto.Email).MaximumLength(255).EmailAddress();
});
RuleFor(a => a.Dto.ContactPhone).MaximumLength(50);
RuleFor(a => a.Dto.BirthDate).NotEmpty();
RuleFor(a => a.Dto.DocumentNumber).NotEmpty().MaximumLength(50);
RuleFor(a => a.Dto.CountryCode).NotEmpty().MaximumLength(3);
ValidationMessage emailOrContactPhoneRequired = validationMessageProvider.PersonEmailOrContactPhoneRequired();
RuleFor(a => a.Dto)
.Must(dto => !string.IsNullOrWhiteSpace(dto.Email) || !string.IsNullOrWhiteSpace(dto.ContactPhone))
.WithMessage(emailOrContactPhoneRequired.Message)
.WithErrorCode(emailOrContactPhoneRequired.Code);
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.Validate);
}
}

View File

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

View File

@@ -0,0 +1,43 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Validation;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Person;
public class UpdatePersonValidator : AbstractValidator<UpdateCommand<PersonDTO>>
{
public UpdatePersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<PersonDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniquePersonDocumentAndCountry(),
t => t.DocumentNumber,
t => t.CountryCode);
RuleFor(a => a.Dto.FirstName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.FirstNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressLine).NotEmpty().MaximumLength(200);
RuleFor(a => a.Dto.PostalCode).NotEmpty().MaximumLength(20);
RuleFor(a => a.Dto.City).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressCountry).NotEmpty().MaximumLength(100);
When(a => !string.IsNullOrWhiteSpace(a.Dto.Email), () =>
{
RuleFor(a => a.Dto.Email).MaximumLength(255).EmailAddress();
});
RuleFor(a => a.Dto.ContactPhone).MaximumLength(50);
RuleFor(a => a.Dto.BirthDate).NotEmpty();
RuleFor(a => a.Dto.DocumentNumber).NotEmpty().MaximumLength(50);
RuleFor(a => a.Dto.CountryCode).NotEmpty().MaximumLength(3);
ValidationMessage emailOrContactPhoneRequired = validationMessageProvider.PersonEmailOrContactPhoneRequired();
RuleFor(a => a.Dto)
.Must(dto => !string.IsNullOrWhiteSpace(dto.Email) || !string.IsNullOrWhiteSpace(dto.ContactPhone))
.WithMessage(emailOrContactPhoneRequired.Message)
.WithErrorCode(emailOrContactPhoneRequired.Code);
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.ValidateExisting);
}
}

View File

@@ -0,0 +1,24 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Registration;
public class AddRegistrationValidator : AbstractValidator<AddCommand<RegistrationDTO, int>>
{
public AddRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueValidator = new UniqueIndexValidator<RegistrationDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniqueRegistration(),
t => t.EventId,
t => t.PersonId,
t => t.SportId);
RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
RuleFor(a => a.Dto).CustomAsync(uniqueValidator.Validate);
}
}

View File

@@ -0,0 +1,13 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Registration;
public class DeleteRegistrationValidator : AbstractValidator<DeleteCommand<RegistrationDTO, int>>
{
public DeleteRegistrationValidator(IMediator mediator)
{
}
}

View File

@@ -0,0 +1,24 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Registration;
public class UpdateRegistrationValidator : AbstractValidator<UpdateCommand<RegistrationDTO>>
{
public UpdateRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueValidator = new UniqueIndexValidator<RegistrationDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniqueRegistration(),
t => t.EventId,
t => t.PersonId,
t => t.SportId);
RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
RuleFor(a => a.Dto).CustomAsync(uniqueValidator.ValidateExisting);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using MediatR;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
namespace Events.WebAPI.Contract.Validation.Sport;
public class AddSportValidator : AbstractValidator<AddCommand<SportDTO, int>>
{
public AddSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<SportDTO, int>(
mediator,
(_, values) => validationMessageProvider.UniqueSportName(values[0]),
t => t.Name);
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(100).DependentRules(() =>
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.Validate));
}
}

View File

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

View File

@@ -0,0 +1,20 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Sport;
public class UpdateSportValidator : AbstractValidator<UpdateCommand<SportDTO>>
{
public UpdateSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<SportDTO, int>(
mediator,
(_, values) => validationMessageProvider.UniqueSportName(values[0]),
t => t.Name);
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(100).DependentRules(() =>
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.ValidateExisting));
}
}

View File

@@ -0,0 +1,206 @@
using System.Linq.Expressions;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Queries.Generic;
using FluentValidation;
using FluentValidation.Results;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public class UniqueIndexValidator<TDto, TPK> where TDto : IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
private readonly IMediator mediator;
private readonly Expression<Func<TDto, object?>>[] selectors;
private readonly Func<IReadOnlyList<string>, IReadOnlyList<string>, ValidationMessage>? errorMessageFactory;
public UniqueIndexValidator(
IMediator mediator,
Func<IReadOnlyList<string>, IReadOnlyList<string>, ValidationMessage>? errorMessageFactory = null,
params Expression<Func<TDto, object?>>[] selectors)
{
this.mediator = mediator;
this.errorMessageFactory = errorMessageFactory;
this.selectors = selectors;
}
public async Task Validate(string value, ValidationContext<AddCommand<TDto, TPK>> context, CancellationToken cancellationToken)
{
string columnName = GetColumnName(context);
var query = new GetCountQuery<TDto>
{
Filters = $"{columnName}==*{EscapeFilterValue(value)}"
};
int count = await mediator.Send(query, cancellationToken);
if (count > 0)
{
ValidationMessage validationMessage = BuildErrorMessage([columnName], [value]);
context.AddFailure(new ValidationFailure(columnName, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
public async Task Validate(TDto dto, ValidationContext<AddCommand<TDto, TPK>> context, CancellationToken cancellationToken)
{
var query = new GetCountQuery<TDto>();
List<string> columnNames = [];
List<string> values = [];
List<string> filters = [];
foreach (var selector in selectors)
{
string columnName = GetColumnName(selector);
columnNames.Add(columnName);
object? rawValue = selector.Compile().Invoke(dto);
string value = FormatValue(rawValue);
values.Add(value);
filters.Add(BuildEqualsFilter(columnName, rawValue));
}
query.Filters = string.Join(",", filters);
int count = await mediator.Send(query, cancellationToken);
if (count > 0)
{
ValidationMessage validationMessage = BuildErrorMessage(columnNames, values);
context.AddFailure(new ValidationFailure(context.PropertyPath, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
public async Task ValidateExisting(string value, ValidationContext<UpdateCommand<TDto>> context, CancellationToken cancellationToken)
{
string columnName = GetColumnName(context);
UpdateCommand<TDto> validatingObject = context.InstanceToValidate;
var query = new GetItemsQuery<TDto>
{
Filters = $"{columnName}==*{EscapeFilterValue(value)}",
Page = 1,
PageSize = 2
};
List<TDto> items = await mediator.Send(query, cancellationToken);
if (items.Count > 0)
{
bool valueBelongsToValidatingItem = items.Any(item => item.Id.Equals(validatingObject.Dto.Id));
if (!valueBelongsToValidatingItem)
{
ValidationMessage validationMessage = BuildErrorMessage([columnName], [value]);
context.AddFailure(new ValidationFailure(columnName, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
}
public async Task ValidateExisting(TDto dto, ValidationContext<UpdateCommand<TDto>> context, CancellationToken cancellationToken)
{
var query = new GetItemsQuery<TDto>();
List<string> columnNames = [];
List<string> values = [];
List<string> filters = [];
foreach (var selector in selectors)
{
string columnName = GetColumnName(selector);
columnNames.Add(columnName);
object? rawValue = selector.Compile().Invoke(dto);
string value = FormatValue(rawValue);
values.Add(value);
filters.Add(BuildEqualsFilter(columnName, rawValue));
}
query.Filters = string.Join(",", filters);
query.Page = 1;
query.PageSize = 2;
List<TDto> items = await mediator.Send(query, cancellationToken);
if (items.Count > 0)
{
bool valueBelongsToValidatingItem = items.Any(item => item.Id.Equals(dto.Id));
if (!valueBelongsToValidatingItem)
{
ValidationMessage validationMessage = BuildErrorMessage(columnNames, values);
context.AddFailure(new ValidationFailure(context.PropertyPath, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
}
private ValidationMessage BuildErrorMessage(IReadOnlyList<string> columnNames, IReadOnlyList<string> values)
{
if (errorMessageFactory != null)
return errorMessageFactory(columnNames, values);
return columnNames.Count == 1
? new ValidationMessage(ValidationErrorCodes.UniqueConstraintViolation, $"{columnNames[0]} must be unique. Value {values[0]} has been already used!")
: new ValidationMessage(ValidationErrorCodes.UniqueConstraintViolation, $"n-tuple ({string.Join(", ", columnNames)}) = ({string.Join(", ", values)}) must be unique.");
}
private string GetColumnName(Expression<Func<TDto, object?>> expression)
{
Expression body = expression.Body;
if (body is UnaryExpression unaryExpression && unaryExpression.NodeType == ExpressionType.Convert)
body = unaryExpression.Operand;
if (body is MemberExpression memberExpression)
return memberExpression.Member.Name;
if (body is MethodCallExpression methodCallExpression && methodCallExpression.Object is MemberExpression objectMemberExpression)
return objectMemberExpression.Member.Name;
throw new Exception($"Invalid nodetype ({body.NodeType}) in expression");
}
private string GetColumnName<T>(ValidationContext<T> context)
{
if (selectors.Length != 1)
throw new Exception($"Unique index contains several columns, and must not be called on a single property {context.PropertyPath}");
Expression body = selectors[0].Body;
if (body is UnaryExpression unaryExpression && unaryExpression.NodeType == ExpressionType.Convert)
body = unaryExpression.Operand;
if (body is not MemberExpression memberExpression)
throw new Exception($"Invalid nodetype ({body.NodeType}) in expression");
string columnName = memberExpression.Member.Name;
if (columnName != context.PropertyPath.Replace(nameof(UpdateCommand<TDto>.Dto) + ".", ""))
throw new Exception($"Unique index is defined on {columnName} but called on {context.PropertyPath}");
return columnName;
}
private static string EscapeFilterValue(string value)
{
string escaped = value
.Replace("\\", "\\\\")
.Replace(",", "\\,")
.Replace("|", "\\|");
return string.Equals(escaped, "null", StringComparison.Ordinal)
? "\\null"
: escaped;
}
private static string BuildEqualsFilter(string columnName, object? value)
{
string formattedValue = FormatValue(value);
return value is string
? $"{columnName}==*{EscapeFilterValue(formattedValue)}"
: $"{columnName}=={formattedValue}";
}
private static string FormatValue(object? value)
{
return value switch
{
null => "null",
string stringValue => stringValue,
DateOnly dateOnlyValue => dateOnlyValue.ToString("yyyy-MM-dd"),
DateTime dateTimeValue => dateTimeValue.ToString("O"),
bool boolValue => boolValue ? "true" : "false",
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty
};
}
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IBaseRequest
{
private readonly IEnumerable<IValidator<TRequest>> validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
{
this.validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,14 @@
namespace Events.WebAPI.Contract.Validation;
public static class ValidationErrorCodes
{
public const string UniqueConstraintViolation = "unique_constraint_violation";
public const string SportNameNotUnique = "sport_name_not_unique";
public const string PersonDocumentCountryNotUnique = "person_document_country_not_unique";
public const string PersonEmailOrContactPhoneRequired = "person_email_or_contact_phone_required";
public const string RegistrationNotUnique = "registration_not_unique";
public const string ForeignKeyNotFound = "foreign_key_not_found";
public const string EventNotFound = "event_not_found";
public const string PersonNotFound = "person_not_found";
public const string SportNotFound = "sport_not_found";
}

View File

@@ -0,0 +1,3 @@
namespace Events.WebAPI.Contract.Validation;
public sealed record ValidationMessage(string Code, string Message);

View File

@@ -0,0 +1,16 @@
using AutoMapper;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Handlers.EF.CommandHandlers.Generic;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Events.WebAPI.Handlers.EF.Models;
using Microsoft.Extensions.Logging;
namespace Events.WebAPI.Handlers.EF.CommandHandlers;
public class EventsCommandsHandler : GenericCommandHandler<Event, EventDTO, int>
{
public EventsCommandsHandler(EventsContext ctx, ILogger<EventsCommandsHandler> logger, IMapper mapper)
: base(ctx, logger, mapper)
{
}
}

View File

@@ -0,0 +1,56 @@
using MobilityOne.Common.Commands;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using AutoMapper;
namespace Events.WebAPI.Handlers.EF.CommandHandlers.Generic;
public class GenericCommandHandler<TDal, TDto, TPK> : IRequestHandler<AddCommand<TDto, TPK>, TPK>,
IRequestHandler<UpdateCommand<TDto>>,
IRequestHandler<DeleteCommand<TDto, TPK>>
where TDal: class, IHasIdAsPK<TPK>
where TDto: IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
protected DbContext Ctx { get; }
protected ILogger Logger { get; }
protected IMapper Mapper { get; }
protected GenericCommandHandler(DbContext ctx, ILogger logger, IMapper mapper)
{
Ctx = ctx;
Logger = logger;
Mapper = mapper;
}
public virtual async Task<TPK> Handle(AddCommand<TDto, TPK> request, CancellationToken cancellationToken)
{
var entity = Mapper.Map<TDto, TDal>(request.Dto);
Ctx.Add(entity);
await Ctx.SaveChangesAsync(cancellationToken);
return entity.Id;
}
public virtual async Task Handle(UpdateCommand<TDto> request, CancellationToken cancellationToken)
{
var entity = await Ctx.Set<TDal>().FindAsync(request.Dto.Id);
if (entity != null)
{
Mapper.Map(request.Dto, entity);
await Ctx.SaveChangesAsync(cancellationToken);
}
else
{
Logger.LogError($"UpdateCommand<{typeof(TDto).Name}> : Invalid id #{request.Dto.Id}");
throw new ArgumentException($"Invalid id: {request.Dto.Id}");
}
}
public virtual async Task Handle(DeleteCommand<TDto, TPK> request, CancellationToken cancellationToken)
{
await Ctx.Set<TDal>().Where(d => d.Id.Equals(request.Id)).ExecuteDeleteAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,16 @@
using AutoMapper;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Handlers.EF.CommandHandlers.Generic;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Events.WebAPI.Handlers.EF.Models;
using Microsoft.Extensions.Logging;
namespace Events.WebAPI.Handlers.EF.CommandHandlers;
public class PeopleCommandsHandler : GenericCommandHandler<Person, PersonDTO, int>
{
public PeopleCommandsHandler(EventsContext ctx, ILogger<PeopleCommandsHandler> logger, IMapper mapper)
: base(ctx, logger, mapper)
{
}
}

View File

@@ -0,0 +1,93 @@
using AutoMapper;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Messages;
using Events.WebAPI.Handlers.EF.CommandHandlers.Generic;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Events.WebAPI.Handlers.EF.Models;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Handlers.EF.CommandHandlers;
public class RegistrationsCommandsHandler : GenericCommandHandler<Registration, RegistrationDTO, int>
{
private readonly IPublishEndpoint publishEndpoint;
public RegistrationsCommandsHandler(
EventsContext ctx,
ILogger<RegistrationsCommandsHandler> logger,
IMapper mapper,
IPublishEndpoint publishEndpoint)
: base(ctx, logger, mapper)
{
this.publishEndpoint = publishEndpoint;
}
public override async Task<int> Handle(AddCommand<RegistrationDTO, int> request, CancellationToken cancellationToken)
{
int id = await base.Handle(request, cancellationToken);
await publishEndpoint.Publish(new RegistrationCreated
{
RegistrationId = id,
PersonId = request.Dto.PersonId,
EventId = request.Dto.EventId,
SportId = request.Dto.SportId
}, cancellationToken);
return id;
}
public override async Task Handle(UpdateCommand<RegistrationDTO> request, CancellationToken cancellationToken)
{
var entity = await Ctx.Set<Registration>().SingleOrDefaultAsync(r => r.Id == request.Dto.Id, cancellationToken);
if (entity == null)
{
Logger.LogError("UpdateCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Dto.Id);
throw new ArgumentException($"Invalid id: {request.Dto.Id}");
}
int previousPersonId = entity.PersonId;
int previousEventId = entity.EventId;
int previousSportId = entity.SportId;
await base.Handle(request, cancellationToken);
await publishEndpoint.Publish(new RegistrationUpdated
{
RegistrationId = request.Dto.Id,
PersonId = request.Dto.PersonId,
EventId = request.Dto.EventId,
SportId = request.Dto.SportId,
PreviousPersonId = previousPersonId,
PreviousEventId = previousEventId,
PreviousSportId = previousSportId
}, cancellationToken);
}
public override async Task Handle(DeleteCommand<RegistrationDTO, int> request, CancellationToken cancellationToken)
{
var entity = await Ctx.Set<Registration>()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.Id == request.Id, cancellationToken);
if (entity == null)
{
Logger.LogError("DeleteCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Id);
throw new ArgumentException($"Invalid id: {request.Id}");
}
await base.Handle(request, cancellationToken);
await publishEndpoint.Publish(new RegistrationDeleted
{
RegistrationId = entity.Id,
PersonId = entity.PersonId,
EventId = entity.EventId,
SportId = entity.SportId
}, cancellationToken);
}
}

View File

@@ -0,0 +1,17 @@
using AutoMapper;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Handlers.EF.CommandHandlers.Generic;
using Events.WebAPI.Handlers.EF.Data.Postgres;
using Events.WebAPI.Handlers.EF.Models;
using Microsoft.Extensions.Logging;
namespace Events.WebAPI.Handlers.EF.CommandHandlers
{
public class SportsCommandsHandler : GenericCommandHandler<Sport, SportDTO, int>
{
public SportsCommandsHandler(EventsContext ctx, ILogger<SportsCommandsHandler> logger, IMapper mapper)
: base(ctx, logger, mapper)
{
}
}
}

View File

@@ -0,0 +1,165 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable enable
using System;
using System.Collections.Generic;
using Events.WebAPI.Handlers.EF.Models;
using Microsoft.EntityFrameworkCore;
namespace Events.WebAPI.Handlers.EF.Data.Postgres;
public partial class EventsContext : DbContext
{
public EventsContext(DbContextOptions<EventsContext> options)
: base(options)
{
}
public virtual DbSet<Country> Countries { get; set; }
public virtual DbSet<Event> Events { get; set; }
public virtual DbSet<Person> People { get; set; }
public virtual DbSet<Registration> Registrations { get; set; }
public virtual DbSet<Sport> Sports { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Country>(entity =>
{
entity.HasKey(e => e.Code).HasName("country_pkey");
entity.ToTable("country");
entity.HasIndex(e => e.Name, "country_name_key").IsUnique();
entity.Property(e => e.Code)
.HasMaxLength(3)
.HasColumnName("code");
entity.Property(e => e.Alpha3)
.HasMaxLength(3)
.IsFixedLength()
.HasColumnName("alpha3");
entity.Property(e => e.Name)
.HasMaxLength(100)
.HasColumnName("name");
entity.Property(e => e.Translations)
.HasColumnType("jsonb")
.HasColumnName("translations");
});
modelBuilder.Entity<Event>(entity =>
{
entity.HasKey(e => e.Id).HasName("event_pkey");
entity.ToTable("event");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.EventDate).HasColumnName("event_date");
entity.Property(e => e.Name)
.HasMaxLength(150)
.HasColumnName("name");
});
modelBuilder.Entity<Person>(entity =>
{
entity.HasKey(e => e.Id).HasName("person_pkey");
entity.ToTable("person");
entity.HasIndex(e => new { e.DocumentNumber, e.CountryCode }, "person_document_number_country_code_key").IsUnique();
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.AddressCountry)
.HasMaxLength(100)
.HasColumnName("address_country");
entity.Property(e => e.AddressLine)
.HasMaxLength(200)
.HasColumnName("address_line");
entity.Property(e => e.BirthDate).HasColumnName("birth_date");
entity.Property(e => e.City)
.HasMaxLength(100)
.HasColumnName("city");
entity.Property(e => e.ContactPhone)
.HasMaxLength(50)
.HasColumnName("contact_phone");
entity.Property(e => e.CountryCode)
.HasMaxLength(3)
.HasColumnName("country_code");
entity.Property(e => e.DocumentNumber)
.HasMaxLength(50)
.HasColumnName("document_number");
entity.Property(e => e.Email)
.HasMaxLength(255)
.HasColumnName("email");
entity.Property(e => e.FirstName)
.HasMaxLength(100)
.HasColumnName("first_name");
entity.Property(e => e.FirstNameTranscription)
.HasMaxLength(100)
.HasColumnName("first_name_transcription");
entity.Property(e => e.LastName)
.HasMaxLength(100)
.HasColumnName("last_name");
entity.Property(e => e.LastNameTranscription)
.HasMaxLength(100)
.HasColumnName("last_name_transcription");
entity.Property(e => e.PostalCode)
.HasMaxLength(20)
.HasColumnName("postal_code");
entity.HasOne(d => d.CountryCodeNavigation).WithMany(p => p.People)
.HasForeignKey(d => d.CountryCode)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("person_country_code_fkey");
});
modelBuilder.Entity<Registration>(entity =>
{
entity.HasKey(e => e.Id).HasName("registration_pkey");
entity.ToTable("registration");
entity.HasIndex(e => new { e.PersonId, e.SportId, e.EventId }, "registration_person_id_sport_id_event_id_key").IsUnique();
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.EventId).HasColumnName("event_id");
entity.Property(e => e.PersonId).HasColumnName("person_id");
entity.Property(e => e.RegisteredAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP")
.HasColumnName("registered_at");
entity.Property(e => e.SportId).HasColumnName("sport_id");
entity.HasOne(d => d.Event).WithMany(p => p.Registrations)
.HasForeignKey(d => d.EventId)
.HasConstraintName("registration_event_id_fkey");
entity.HasOne(d => d.Person).WithMany(p => p.Registrations)
.HasForeignKey(d => d.PersonId)
.HasConstraintName("registration_person_id_fkey");
entity.HasOne(d => d.Sport).WithMany(p => p.Registrations)
.HasForeignKey(d => d.SportId)
.HasConstraintName("registration_sport_id_fkey");
});
modelBuilder.Entity<Sport>(entity =>
{
entity.HasKey(e => e.Id).HasName("sport_pkey");
entity.ToTable("sport");
entity.HasIndex(e => e.Name, "sport_name_key").IsUnique();
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.Name)
.HasMaxLength(100)
.HasColumnName("name");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Events.WebAPI.Contract\Events.WebAPI.Contract.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="MassTransit" Version="8.5.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using AutoMapper;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Handlers.EF.Models;
namespace Events.WebAPI.Handlers.EF.Mappings;
public class EFMappingProfile : Profile
{
public EFMappingProfile()
{
CreateMap<Event, EventDTO>()
.ForMember(o => o.RegistrationsCount, opt => opt.MapFrom(src => src.Registrations.Count));
CreateMap<EventDTO, Event>()
.ForMember(o => o.Id, opt => opt.Ignore());
CreateMap<Person, PersonDTO>()
.ForMember(o => o.CountryName, opt => opt.MapFrom(src => src.CountryCodeNavigation.Name))
.ForMember(o => o.FullNameTranscription, opt => opt.MapFrom(src => src.FirstNameTranscription + " " + src.LastNameTranscription))
.ForMember(o => o.RegistrationsCount, opt => opt.MapFrom(src => src.Registrations.Count));
CreateMap<PersonDTO, Person>()
.ForMember(o => o.Id, opt => opt.Ignore());
CreateMap<Registration, RegistrationDTO>()
.ForMember(o => o.PersonName, opt => opt.MapFrom(src => src.Person.FirstName + " " + src.Person.LastName))
.ForMember(o => o.PersonTranscription, opt => opt.MapFrom(src => src.Person.FirstNameTranscription + " " + src.Person.LastNameTranscription))
.ForMember(o => o.PersonFirstNameTranscription, opt => opt.MapFrom(src => src.Person.FirstNameTranscription))
.ForMember(o => o.PersonLastNameTranscription, opt => opt.MapFrom(src => src.Person.LastNameTranscription))
.ForMember(o => o.CountryCode, opt => opt.MapFrom(src => src.Person.CountryCode))
.ForMember(o => o.CountryName, opt => opt.MapFrom(src => src.Person.CountryCodeNavigation.Name))
.ForMember(o => o.SportName, opt => opt.MapFrom(src => src.Sport.Name));
CreateMap<RegistrationDTO, Registration>()
.ForMember(o => o.Id, opt => opt.Ignore())
.ForMember(o => o.RegisteredAt, opt => opt.Ignore());
CreateMap<Sport, SportDTO>();
CreateMap<SportDTO, Sport>()
.ForMember(o => o.Id, opt => opt.Ignore());
}
}

View File

@@ -0,0 +1,7 @@
using Events.WebAPI.Contract.DTOs;
namespace Events.WebAPI.Handlers.EF.Models;
public partial class Event : IHasIdAsPK<int>
{
}

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