WebApi + ClientApp, GraphQL, Reflection
This commit is contained in:
6
Events-WebApi/Events-WebApi.slnx
Normal file
6
Events-WebApi/Events-WebApi.slnx
Normal 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>
|
||||||
6
Events-WebApi/Events.ClientApp/.env.example
Normal file
6
Events-WebApi/Events.ClientApp/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com
|
||||||
|
VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3
|
||||||
|
VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api
|
||||||
|
VITE_AUTH0_SCOPE=openid profile email events:read events:write
|
||||||
|
VITE_API_BASE_URL=https://localhost:7295
|
||||||
|
VITE_FILES_API_BASE_URL=https://localhost:7296
|
||||||
113
Events-WebApi/Events.ClientApp/README.md
Normal file
113
Events-WebApi/Events.ClientApp/README.md
Normal 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
|
||||||
12
Events-WebApi/Events.ClientApp/index.html
Normal file
12
Events-WebApi/Events.ClientApp/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Events Client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Events-WebApi/Events.ClientApp/package.json
Normal file
25
Events-WebApi/Events.ClientApp/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "events-clientapp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth0/auth0-vue": "^2.5.0",
|
||||||
|
"@primeuix/themes": "^2.0.3",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.3.2",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vue-tsc": "^2.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal file
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useAuth0 } from '@auth0/auth0-vue';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
import Menu from 'primevue/menu';
|
||||||
|
import Tab from 'primevue/tab';
|
||||||
|
import TabList from 'primevue/tablist';
|
||||||
|
import TabPanel from 'primevue/tabpanel';
|
||||||
|
import TabPanels from 'primevue/tabpanels';
|
||||||
|
import Tabs from 'primevue/tabs';
|
||||||
|
import Toast from 'primevue/toast';
|
||||||
|
import EventsPanel from './components/EventsPanel.vue';
|
||||||
|
import PeoplePanel from './components/PeoplePanel.vue';
|
||||||
|
import RegistrationsPanel from './components/RegistrationsPanel.vue';
|
||||||
|
import SportsPanel from './components/SportsPanel.vue';
|
||||||
|
import { activeTab } from './state/uiState';
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
error,
|
||||||
|
loginWithRedirect,
|
||||||
|
logout: auth0Logout,
|
||||||
|
user
|
||||||
|
} = useAuth0();
|
||||||
|
|
||||||
|
const sportsPanelRef = ref<{ openCreate: () => void } | null>(null);
|
||||||
|
const eventsPanelRef = ref<{ openCreate: () => void } | null>(null);
|
||||||
|
const peoplePanelRef = ref<{ openCreate: () => void } | null>(null);
|
||||||
|
const registrationsPanelRef = ref<{ openCreate: () => void } | null>(null);
|
||||||
|
const userMenuRef = ref<{ toggle: (event: Event) => void } | null>(null);
|
||||||
|
|
||||||
|
const actionLabel = computed(() => {
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'events':
|
||||||
|
return 'New event';
|
||||||
|
case 'people':
|
||||||
|
return 'New person';
|
||||||
|
case 'registrations':
|
||||||
|
return 'New registration';
|
||||||
|
case 'sports':
|
||||||
|
default:
|
||||||
|
return 'New sport';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openActiveCreate() {
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'events':
|
||||||
|
eventsPanelRef.value?.openCreate();
|
||||||
|
break;
|
||||||
|
case 'people':
|
||||||
|
peoplePanelRef.value?.openCreate();
|
||||||
|
break;
|
||||||
|
case 'registrations':
|
||||||
|
registrationsPanelRef.value?.openCreate();
|
||||||
|
break;
|
||||||
|
case 'sports':
|
||||||
|
default:
|
||||||
|
sportsPanelRef.value?.openCreate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
return loginWithRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
return auth0Logout({ logoutParams: { returnTo: window.location.origin } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenuItems = [
|
||||||
|
{
|
||||||
|
label: 'Logout',
|
||||||
|
icon: 'pi pi-sign-out',
|
||||||
|
command: logout
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleUserMenu(event: Event) {
|
||||||
|
userMenuRef.value?.toggle(event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<Toast position="top-right" />
|
||||||
|
<ConfirmDialog />
|
||||||
|
<Menu ref="userMenuRef" :model="userMenuItems" popup />
|
||||||
|
|
||||||
|
<section v-if="isLoading" class="workspace auth-state">
|
||||||
|
<div class="panel-card auth-card">Loading...</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="!isAuthenticated" class="workspace auth-state">
|
||||||
|
<div class="panel-card auth-card">
|
||||||
|
<h1>Events</h1>
|
||||||
|
<p v-if="error">Error: {{ error.message }}</p>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<Button label="Login" @click="login" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="workspace">
|
||||||
|
<Tabs v-model:value="activeTab">
|
||||||
|
<div class="tabs-header-bar">
|
||||||
|
<TabList>
|
||||||
|
<Tab value="sports">Sports</Tab>
|
||||||
|
<Tab value="events">Events</Tab>
|
||||||
|
<Tab value="people">People</Tab>
|
||||||
|
<Tab value="registrations">Registrations</Tab>
|
||||||
|
</TabList>
|
||||||
|
<div class="tabs-header-center">
|
||||||
|
<Button :label="actionLabel" icon="pi pi-plus" @click="openActiveCreate" />
|
||||||
|
</div>
|
||||||
|
<div class="tabs-header-actions">
|
||||||
|
<button class="user-chip user-chip-button" type="button" @click="toggleUserMenu($event)">
|
||||||
|
{{ user?.email || user?.name || 'Authenticated user' }}
|
||||||
|
<span class="pi pi-angle-down" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel value="sports">
|
||||||
|
<SportsPanel ref="sportsPanelRef" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="events">
|
||||||
|
<EventsPanel ref="eventsPanelRef" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="people">
|
||||||
|
<PeoplePanel ref="peoplePanelRef" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="registrations">
|
||||||
|
<RegistrationsPanel ref="registrationsPanelRef" />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal file
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { deleteJson, getFile, getJson, postJson, putJson } from './http';
|
||||||
|
import type {
|
||||||
|
EventDto,
|
||||||
|
IdName,
|
||||||
|
ItemsResponse,
|
||||||
|
PageState,
|
||||||
|
PersonDto,
|
||||||
|
RegistrationDto,
|
||||||
|
RegistrationUpsertDto,
|
||||||
|
SportDto
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function toQuery(pageState: PageState) {
|
||||||
|
return {
|
||||||
|
page: pageState.page,
|
||||||
|
pageSize: pageState.pageSize,
|
||||||
|
sort: pageState.sort,
|
||||||
|
sortOrder: pageState.sortOrder,
|
||||||
|
filters: pageState.filters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sportsApi = {
|
||||||
|
list: (pageState: PageState) => getJson<ItemsResponse<SportDto>>('/Sports', toQuery(pageState)),
|
||||||
|
get: (id: number) => getJson<SportDto>(`/Sports/${id}`),
|
||||||
|
create: (payload: SportDto) => postJson<SportDto, SportDto>('/Sports', payload),
|
||||||
|
update: (payload: SportDto) => putJson(`/Sports/${payload.id}`, payload),
|
||||||
|
remove: (id: number) => deleteJson(`/Sports/${id}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventsApi = {
|
||||||
|
list: (pageState: PageState) => getJson<ItemsResponse<EventDto>>('/Events', toQuery(pageState)),
|
||||||
|
get: (id: number) => getJson<EventDto>(`/Events/${id}`),
|
||||||
|
create: (payload: EventDto) => postJson<EventDto, EventDto>('/Events', payload),
|
||||||
|
update: (payload: EventDto) => putJson(`/Events/${payload.id}`, payload),
|
||||||
|
remove: (id: number) => deleteJson(`/Events/${id}`),
|
||||||
|
downloadRegistrationsExcel: (id: number) => getFile(`/Events/${id}/RegistrationsExcel`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const peopleApi = {
|
||||||
|
list: (pageState: PageState) => getJson<ItemsResponse<PersonDto>>('/People', toQuery(pageState)),
|
||||||
|
get: (id: number) => getJson<PersonDto>(`/People/${id}`),
|
||||||
|
create: (payload: PersonDto) => postJson<PersonDto, PersonDto>('/People', payload),
|
||||||
|
update: (payload: PersonDto) => putJson(`/People/${payload.id}`, payload),
|
||||||
|
remove: (id: number) => deleteJson(`/People/${id}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registrationsApi = {
|
||||||
|
list: (pageState: PageState) => getJson<ItemsResponse<RegistrationDto>>('/Registrations', toQuery(pageState)),
|
||||||
|
get: (id: number) => getJson<RegistrationDto>(`/Registrations/${id}`),
|
||||||
|
create: (payload: RegistrationUpsertDto) => postJson<RegistrationDto, RegistrationUpsertDto>('/Registrations', payload),
|
||||||
|
update: (payload: RegistrationUpsertDto) => putJson(`/Registrations/${payload.id}`, payload),
|
||||||
|
remove: (id: number) => deleteJson(`/Registrations/${id}`),
|
||||||
|
downloadCertificate: (id: number) => getFile(`/Registrations/${id}/Certificate`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lookupApi = {
|
||||||
|
countries: (text?: string) => getJson<Array<IdName<string>>>('/Lookup/Countries', { text }),
|
||||||
|
people: (text?: string, countryCode?: string) =>
|
||||||
|
getJson<Array<IdName<number>>>('/Lookup/People', { text, countryCode })
|
||||||
|
};
|
||||||
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal file
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { auth0 } from '../auth';
|
||||||
|
import type { ProblemDetails } from './types';
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://localhost:7150';
|
||||||
|
const filesApiBaseUrl = import.meta.env.VITE_FILES_API_BASE_URL ?? 'https://localhost:7296';
|
||||||
|
|
||||||
|
function toCamelCase(value: string) {
|
||||||
|
return value.length > 0 ? value[0].toLowerCase() + value.slice(1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJson<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeJson(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && !(value instanceof Date)) {
|
||||||
|
const normalizedEntries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
|
||||||
|
toCamelCase(key),
|
||||||
|
normalizeJson(entryValue)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Object.fromEntries(normalizedEntries) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(
|
||||||
|
path: string,
|
||||||
|
query?: Record<string, string | number | undefined | null>,
|
||||||
|
baseUrl: string = apiBaseUrl
|
||||||
|
) {
|
||||||
|
const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (response.ok) {
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as T;
|
||||||
|
return normalizeJson(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
let problem: ProblemDetails | undefined;
|
||||||
|
try {
|
||||||
|
problem = normalizeJson((await response.json()) as ProblemDetails);
|
||||||
|
} catch {
|
||||||
|
problem = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationMessage = problem?.errors
|
||||||
|
? Object.entries(problem.errors)
|
||||||
|
.flatMap(([field, messages]) => messages.map((message) => (field ? `${field}: ${message}` : message)))
|
||||||
|
.join('\n')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
throw new Error(validationMessage || problem?.detail || problem?.title || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAuthHeaders() {
|
||||||
|
if (!auth0.isAuthenticated.value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal file
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export interface ItemsResponse<T> {
|
||||||
|
data: T[] | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdName<T> {
|
||||||
|
id: T;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SportDto {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDto {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
eventDate: string;
|
||||||
|
registrationsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonDto {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
firstNameTranscription: string;
|
||||||
|
lastNameTranscription: string;
|
||||||
|
addressLine: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
addressCountry: string;
|
||||||
|
email: string;
|
||||||
|
contactPhone: string;
|
||||||
|
birthDate: string;
|
||||||
|
documentNumber: string;
|
||||||
|
countryCode: string;
|
||||||
|
countryName: string;
|
||||||
|
fullNameTranscription: string;
|
||||||
|
registrationsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationDto {
|
||||||
|
id: number;
|
||||||
|
eventId: number;
|
||||||
|
personId: number;
|
||||||
|
sportId: number;
|
||||||
|
registeredAt: string | null;
|
||||||
|
personName: string;
|
||||||
|
personTranscription: string;
|
||||||
|
personFirstNameTranscription: string;
|
||||||
|
personLastNameTranscription: string;
|
||||||
|
countryCode: string;
|
||||||
|
countryName: string;
|
||||||
|
sportName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationUpsertDto {
|
||||||
|
id: number;
|
||||||
|
eventId: number;
|
||||||
|
personId: number;
|
||||||
|
sportId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetails {
|
||||||
|
title?: string;
|
||||||
|
detail?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
errorCodes?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageState {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sort?: string;
|
||||||
|
sortOrder?: 1 | -1;
|
||||||
|
filters?: string;
|
||||||
|
}
|
||||||
19
Events-WebApi/Events.ClientApp/src/auth.ts
Normal file
19
Events-WebApi/Events.ClientApp/src/auth.ts
Normal 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
|
||||||
|
});
|
||||||
319
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal file
319
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal 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>
|
||||||
431
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal file
431
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
227
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal file
227
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal 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>
|
||||||
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal file
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
|
import PrimeVue from 'primevue/config';
|
||||||
|
import ToastService from 'primevue/toastservice';
|
||||||
|
import Aura from '@primeuix/themes/aura';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { auth0 } from './auth';
|
||||||
|
import 'primeicons/primeicons.css';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.use(auth0);
|
||||||
|
app.use(ConfirmationService);
|
||||||
|
app.use(ToastService);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal file
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const eventsCatalogVersion = ref(0);
|
||||||
|
export const sportsCatalogVersion = ref(0);
|
||||||
|
export const peopleCatalogVersion = ref(0);
|
||||||
|
|
||||||
|
export function touchEventsCatalog() {
|
||||||
|
eventsCatalogVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchSportsCatalog() {
|
||||||
|
sportsCatalogVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchPeopleCatalog() {
|
||||||
|
peopleCatalogVersion.value += 1;
|
||||||
|
}
|
||||||
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal file
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const activeTab = ref('sports');
|
||||||
|
export const pendingRegistrationEventId = ref<number | null>(null);
|
||||||
|
|
||||||
|
export function openRegistrationCreateForEvent(eventId: number) {
|
||||||
|
pendingRegistrationEventId.value = eventId;
|
||||||
|
activeTab.value = 'registrations';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingRegistrationEventId() {
|
||||||
|
const eventId = pendingRegistrationEventId.value;
|
||||||
|
pendingRegistrationEventId.value = null;
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
224
Events-WebApi/Events.ClientApp/src/style.css
Normal file
224
Events-WebApi/Events.ClientApp/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal file
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export function toDate(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateOnlyString(value: Date | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = `${value.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${value.getDate()}`.padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateOnly(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('hr-HR').format(toDate(value) ?? new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('hr-HR', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal file
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "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"]
|
||||||
|
}
|
||||||
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
26
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal file
26
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal 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>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text;
|
||||||
|
using PdfSharpCore;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Fonts;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Utils;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates;
|
||||||
|
|
||||||
|
internal static class CertificatePdfDocumentWriter
|
||||||
|
{
|
||||||
|
private const string FontFamilyName = "Arial";
|
||||||
|
private static int initialized;
|
||||||
|
|
||||||
|
public static byte[] CreateCertificate(CertificatePdfModel model)
|
||||||
|
{
|
||||||
|
EnsureFontsConfigured();
|
||||||
|
|
||||||
|
using var document = new PdfDocument();
|
||||||
|
PdfPage page = document.AddPage();
|
||||||
|
page.Size = PageSize.A4;
|
||||||
|
|
||||||
|
using XGraphics graphics = XGraphics.FromPdfPage(page);
|
||||||
|
var titleFont = new XFont(FontFamilyName, 20, XFontStyle.Bold);
|
||||||
|
var headingFont = new XFont(FontFamilyName, 13, XFontStyle.Bold);
|
||||||
|
var textFont = new XFont(FontFamilyName, 12, XFontStyle.Regular);
|
||||||
|
|
||||||
|
double marginLeft = 50;
|
||||||
|
double y = 60;
|
||||||
|
double contentWidth = page.Width - marginLeft * 2;
|
||||||
|
|
||||||
|
graphics.DrawString(model.Title, titleFont, XBrushes.DarkBlue, new XRect(marginLeft, y, contentWidth, 30), XStringFormats.TopLeft);
|
||||||
|
y += 52;
|
||||||
|
|
||||||
|
foreach (string paragraph in BuildParagraphs(model))
|
||||||
|
{
|
||||||
|
DrawParagraph(graphics, paragraph, textFont, marginLeft, ref y, contentWidth);
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
graphics.DrawString("Sports", headingFont, XBrushes.Black, new XRect(marginLeft, y, contentWidth, 20), XStringFormats.TopLeft);
|
||||||
|
y += 26;
|
||||||
|
|
||||||
|
foreach (string sportName in model.SportNames)
|
||||||
|
{
|
||||||
|
DrawParagraph(graphics, $"- {sportName}", textFont, marginLeft + 12, ref y, contentWidth - 12);
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
y += 12;
|
||||||
|
DrawParagraph(graphics, $"Event ID: {model.EventId}", textFont, marginLeft, ref y, contentWidth);
|
||||||
|
y += 4;
|
||||||
|
DrawParagraph(graphics, $"Person ID: {model.PersonId}", textFont, marginLeft, ref y, contentWidth);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
document.Save(stream, false);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureFontsConfigured()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref initialized, 1) == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GlobalFontSettings.FontResolver = new FontResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> BuildParagraphs(CertificatePdfModel model)
|
||||||
|
{
|
||||||
|
yield return $"This confirms that {model.PersonFullName} participated in the event \"{model.EventName}\".";
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(model.PersonFullNameTranscription) &&
|
||||||
|
!string.Equals(model.PersonFullName, model.PersonFullNameTranscription, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return $"Transcribed full name: {model.PersonFullNameTranscription}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return $"Event date: {model.EventDate:dd.MM.yyyy}.";
|
||||||
|
yield return "The person competed in the following sports:";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawParagraph(XGraphics graphics, string text, XFont font, double left, ref double y, double width)
|
||||||
|
{
|
||||||
|
foreach (string line in WrapText(graphics, text, font, width))
|
||||||
|
{
|
||||||
|
graphics.DrawString(line, font, XBrushes.Black, new XRect(left, y, width, 18), XStringFormats.TopLeft);
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> WrapText(XGraphics graphics, string text, XFont font, double width)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
yield return string.Empty;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var lineBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (string word in words)
|
||||||
|
{
|
||||||
|
string candidate = lineBuilder.Length == 0 ? word : $"{lineBuilder} {word}";
|
||||||
|
if (graphics.MeasureString(candidate, font).Width <= width)
|
||||||
|
{
|
||||||
|
lineBuilder.Clear();
|
||||||
|
lineBuilder.Append(candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineBuilder.Length > 0)
|
||||||
|
{
|
||||||
|
yield return lineBuilder.ToString();
|
||||||
|
lineBuilder.Clear();
|
||||||
|
lineBuilder.Append(word);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
yield return word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineBuilder.Length > 0)
|
||||||
|
yield return lineBuilder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Events.FilesAPI.Features.Certificates;
|
||||||
|
|
||||||
|
internal sealed class CertificatePdfModel
|
||||||
|
{
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string PersonFullName { get; init; } = string.Empty;
|
||||||
|
public string PersonFullNameTranscription { get; init; } = string.Empty;
|
||||||
|
public string EventName { get; init; } = string.Empty;
|
||||||
|
public DateOnly EventDate { get; init; }
|
||||||
|
public int EventId { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public IReadOnlyList<string> SportNames { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Events.WebAPI.Contract.Messages;
|
||||||
|
using Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
using MassTransit;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates;
|
||||||
|
|
||||||
|
public class CertificateRegistrationEventsConsumer :
|
||||||
|
IConsumer<RegistrationCreated>,
|
||||||
|
IConsumer<RegistrationUpdated>,
|
||||||
|
IConsumer<RegistrationDeleted>
|
||||||
|
{
|
||||||
|
private readonly IMediator mediator;
|
||||||
|
|
||||||
|
public CertificateRegistrationEventsConsumer(IMediator mediator)
|
||||||
|
{
|
||||||
|
this.mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<RegistrationCreated> context)
|
||||||
|
{
|
||||||
|
return mediator.Send(new SynchronizeCertificateCommand(
|
||||||
|
context.Message.EventId,
|
||||||
|
context.Message.PersonId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Consume(ConsumeContext<RegistrationUpdated> context)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SynchronizeCertificateCommand(
|
||||||
|
context.Message.EventId,
|
||||||
|
context.Message.PersonId), context.CancellationToken);
|
||||||
|
|
||||||
|
if (context.Message.PreviousEventId != context.Message.EventId ||
|
||||||
|
context.Message.PreviousPersonId != context.Message.PersonId)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SynchronizeCertificateCommand(
|
||||||
|
context.Message.PreviousEventId,
|
||||||
|
context.Message.PreviousPersonId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<RegistrationDeleted> context)
|
||||||
|
{
|
||||||
|
return mediator.Send(new SynchronizeCertificateCommand(
|
||||||
|
context.Message.EventId,
|
||||||
|
context.Message.PersonId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using Events.FilesAPI.Infrastructure.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||||
|
|
||||||
|
public sealed class CertificateFileLocator(
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
IOptions<GeneratedFilesOptions> generatedFilesOptions)
|
||||||
|
{
|
||||||
|
public GeneratedFileReference? TryGet(int eventId, int personId)
|
||||||
|
{
|
||||||
|
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||||
|
? generatedFilesOptions.Value.OutputPath
|
||||||
|
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||||
|
|
||||||
|
string certificatePath = Path.Combine(
|
||||||
|
rootPath,
|
||||||
|
eventId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
$"{eventId}-{personId}.pdf");
|
||||||
|
|
||||||
|
if (!File.Exists(certificatePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new GeneratedFileReference
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileName(certificatePath),
|
||||||
|
PhysicalPath = certificatePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||||
|
|
||||||
|
public sealed class DownloadCertificateHandler(
|
||||||
|
EventsContext context,
|
||||||
|
CertificateFileGenerator generator,
|
||||||
|
CertificateFileLocator fileLocator) : IRequestHandler<DownloadCertificateQuery, DownloadCertificateResult>
|
||||||
|
{
|
||||||
|
public async Task<DownloadCertificateResult> Handle(DownloadCertificateQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var registration = await context.Registrations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.Id == request.RegistrationId)
|
||||||
|
.Select(r => new { r.EventId, r.PersonId })
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (registration == null)
|
||||||
|
return new DownloadCertificateResult(false, null);
|
||||||
|
|
||||||
|
GeneratedFileReference? file = fileLocator.TryGet(registration.EventId, registration.PersonId);
|
||||||
|
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
await generator.GenerateAsync(registration.EventId, registration.PersonId, cancellationToken);
|
||||||
|
file = fileLocator.TryGet(registration.EventId, registration.PersonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadCertificateResult(true, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||||
|
|
||||||
|
public sealed record DownloadCertificateQuery(int RegistrationId) : IRequest<DownloadCertificateResult>;
|
||||||
|
|
||||||
|
public sealed record DownloadCertificateResult(bool RegistrationFound, GeneratedFileReference? File);
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Events.FilesAPI.Infrastructure.Options;
|
||||||
|
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||||
|
using Events.WebAPI.Handlers.EF.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
|
||||||
|
public sealed class CertificateFileGenerator(
|
||||||
|
EventsContext context,
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
IOptions<GeneratedFilesOptions> generatedFilesOptions,
|
||||||
|
ILogger<CertificateFileGenerator> logger)
|
||||||
|
{
|
||||||
|
public async Task GenerateAsync(int eventId, int personId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var registrations = await context.Set<Registration>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.EventId == eventId && r.PersonId == personId)
|
||||||
|
.OrderBy(r => r.Sport.Name)
|
||||||
|
.Select(r => new CertificateRegistrationData
|
||||||
|
{
|
||||||
|
EventId = r.EventId,
|
||||||
|
EventName = r.Event.Name,
|
||||||
|
EventDate = r.Event.EventDate,
|
||||||
|
PersonId = r.PersonId,
|
||||||
|
FirstName = r.Person.FirstName,
|
||||||
|
LastName = r.Person.LastName,
|
||||||
|
FirstNameTranscription = r.Person.FirstNameTranscription,
|
||||||
|
LastNameTranscription = r.Person.LastNameTranscription,
|
||||||
|
SportName = r.Sport.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
string certificatePath = GetCertificatePath(eventId, personId);
|
||||||
|
if (registrations.Count == 0)
|
||||||
|
{
|
||||||
|
DeleteCertificateIfExists(certificatePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
|
||||||
|
|
||||||
|
byte[] pdfBytes = CertificatePdfDocumentWriter.CreateCertificate(BuildModel(registrations));
|
||||||
|
await File.WriteAllBytesAsync(certificatePath, pdfBytes, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Registration certificate generated for event #{EventId}, person #{PersonId} at {Path}",
|
||||||
|
eventId,
|
||||||
|
personId,
|
||||||
|
certificatePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCertificatePath(int eventId, int personId)
|
||||||
|
{
|
||||||
|
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||||
|
? generatedFilesOptions.Value.OutputPath
|
||||||
|
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||||
|
|
||||||
|
return Path.Combine(rootPath, eventId.ToString(CultureInfo.InvariantCulture), $"{eventId}-{personId}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCertificateIfExists(string certificatePath)
|
||||||
|
{
|
||||||
|
DeleteFileIfExists(certificatePath, "Registration certificate deleted at {Path}");
|
||||||
|
|
||||||
|
string? directory = Path.GetDirectoryName(certificatePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory) &&
|
||||||
|
Directory.Exists(directory) &&
|
||||||
|
!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
|
{
|
||||||
|
Directory.Delete(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteFileIfExists(string path, string logMessage)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
File.Delete(path);
|
||||||
|
logger.LogInformation(logMessage, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CertificatePdfModel BuildModel(IReadOnlyList<CertificateRegistrationData> registrations)
|
||||||
|
{
|
||||||
|
CertificateRegistrationData first = registrations[0];
|
||||||
|
string originalFullName = $"{first.FirstName} {first.LastName}".Trim();
|
||||||
|
string transcriptionFullName = $"{first.FirstNameTranscription} {first.LastNameTranscription}".Trim();
|
||||||
|
|
||||||
|
var sports = registrations
|
||||||
|
.Select(r => r.SportName)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CertificatePdfModel
|
||||||
|
{
|
||||||
|
Title = "Certificate of participation",
|
||||||
|
PersonFullName = originalFullName,
|
||||||
|
PersonFullNameTranscription = transcriptionFullName,
|
||||||
|
EventName = first.EventName,
|
||||||
|
EventDate = first.EventDate,
|
||||||
|
EventId = first.EventId,
|
||||||
|
PersonId = first.PersonId,
|
||||||
|
SportNames = sports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CertificateRegistrationData
|
||||||
|
{
|
||||||
|
public int EventId { get; init; }
|
||||||
|
public string EventName { get; init; } = string.Empty;
|
||||||
|
public DateOnly EventDate { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public string? FirstName { get; init; } = string.Empty;
|
||||||
|
public string? LastName { get; init; } = string.Empty;
|
||||||
|
public string FirstNameTranscription { get; init; } = string.Empty;
|
||||||
|
public string LastNameTranscription { get; init; } = string.Empty;
|
||||||
|
public string SportName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
|
||||||
|
public sealed record SynchronizeCertificateCommand(int EventId, int PersonId) : IRequest;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||||
|
|
||||||
|
public sealed class SynchronizeCertificateHandler(
|
||||||
|
CertificateFileGenerator generator) : IRequestHandler<SynchronizeCertificateCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SynchronizeCertificateCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await generator.GenerateAsync(request.EventId, request.PersonId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||||
|
|
||||||
|
public sealed class DownloadRegistrationsExcelHandler(
|
||||||
|
EventsContext context,
|
||||||
|
RegistrationsExcelFileGenerator generator,
|
||||||
|
RegistrationsExcelFileLocator fileLocator) : IRequestHandler<DownloadRegistrationsExcelQuery, DownloadRegistrationsExcelResult>
|
||||||
|
{
|
||||||
|
public async Task<DownloadRegistrationsExcelResult> Handle(DownloadRegistrationsExcelQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool exists = await context.Events
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(e => e.Id == request.EventId, cancellationToken);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
return new DownloadRegistrationsExcelResult(false, null);
|
||||||
|
|
||||||
|
GeneratedFileReference? file = fileLocator.TryGet(request.EventId);
|
||||||
|
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
await generator.GenerateAsync(request.EventId, cancellationToken);
|
||||||
|
file = fileLocator.TryGet(request.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadRegistrationsExcelResult(true, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||||
|
|
||||||
|
public sealed record DownloadRegistrationsExcelQuery(int EventId) : IRequest<DownloadRegistrationsExcelResult>;
|
||||||
|
|
||||||
|
public sealed record DownloadRegistrationsExcelResult(bool EventFound, GeneratedFileReference? File);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Files;
|
||||||
|
using Events.FilesAPI.Infrastructure.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||||
|
|
||||||
|
public sealed class RegistrationsExcelFileLocator(
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
IOptions<GeneratedFilesOptions> generatedFilesOptions)
|
||||||
|
{
|
||||||
|
public GeneratedFileReference? TryGet(int eventId)
|
||||||
|
{
|
||||||
|
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||||
|
? generatedFilesOptions.Value.OutputPath
|
||||||
|
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||||
|
|
||||||
|
string excelPath = Path.Combine(rootPath, $"{eventId}.xlsx");
|
||||||
|
if (!File.Exists(excelPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new GeneratedFileReference
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileName(excelPath),
|
||||||
|
PhysicalPath = excelPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using LargeXlsx;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel;
|
||||||
|
|
||||||
|
internal static class EventRegistrationsExcelWriter
|
||||||
|
{
|
||||||
|
public static async Task WriteAsync(
|
||||||
|
string path,
|
||||||
|
IQueryable<RowData> rows,
|
||||||
|
RowData firstRow,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
await using var writer = new XlsxWriter(stream);
|
||||||
|
|
||||||
|
string[] headers =
|
||||||
|
[
|
||||||
|
"Registration ID",
|
||||||
|
"Registration date",
|
||||||
|
"Person ID",
|
||||||
|
"Last name",
|
||||||
|
"First name",
|
||||||
|
"Last name transcription",
|
||||||
|
"First name transcription",
|
||||||
|
"Country",
|
||||||
|
"Sport"
|
||||||
|
];
|
||||||
|
|
||||||
|
string[] firstRowValues = GetRowValues(firstRow);
|
||||||
|
var columns = headers
|
||||||
|
.Select((header, index) => XlsxColumn.Formatted(GetWidth(header, firstRowValues[index])))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
writer
|
||||||
|
.BeginWorksheet("Registrations", columns: columns)
|
||||||
|
.BeginRow();
|
||||||
|
|
||||||
|
foreach (string header in headers)
|
||||||
|
{
|
||||||
|
writer.Write(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foreach (RowData row in rows.AsAsyncEnumerable().WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
writer
|
||||||
|
.BeginRow()
|
||||||
|
.Write(row.RegistrationId)
|
||||||
|
.Write(FormatRegisteredAt(row.RegisteredAt))
|
||||||
|
.Write(row.PersonId)
|
||||||
|
.Write(row.LastName)
|
||||||
|
.Write(row.FirstName)
|
||||||
|
.Write(row.LastNameTranscription)
|
||||||
|
.Write(row.FirstNameTranscription)
|
||||||
|
.Write(row.CountryName)
|
||||||
|
.Write(row.SportName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] GetRowValues(RowData row)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
row.RegistrationId.ToString(),
|
||||||
|
FormatRegisteredAt(row.RegisteredAt),
|
||||||
|
row.PersonId.ToString(),
|
||||||
|
row.LastName,
|
||||||
|
row.FirstName,
|
||||||
|
row.LastNameTranscription,
|
||||||
|
row.FirstNameTranscription,
|
||||||
|
row.CountryName,
|
||||||
|
row.SportName
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetWidth(string header, string sample)
|
||||||
|
{
|
||||||
|
int maxLength = Math.Max(header.Length, sample.Length);
|
||||||
|
double paddedWidth = Math.Ceiling(maxLength * 1.25d + 4d);
|
||||||
|
return Math.Clamp(paddedWidth, 10d, 60d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatRegisteredAt(DateTime? value)
|
||||||
|
{
|
||||||
|
return value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm:ss") : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RowData
|
||||||
|
{
|
||||||
|
public int RegistrationId { get; init; }
|
||||||
|
public DateTime? RegisteredAt { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public string FirstName { get; init; } = string.Empty;
|
||||||
|
public string LastName { get; init; } = string.Empty;
|
||||||
|
public string FirstNameTranscription { get; init; } = string.Empty;
|
||||||
|
public string LastNameTranscription { get; init; } = string.Empty;
|
||||||
|
public string CountryName { get; init; } = string.Empty;
|
||||||
|
public string SportName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Events.WebAPI.Contract.Messages;
|
||||||
|
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
using MassTransit;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel;
|
||||||
|
|
||||||
|
public class RegistrationsExcelEventsConsumer :
|
||||||
|
IConsumer<RegistrationCreated>,
|
||||||
|
IConsumer<RegistrationUpdated>,
|
||||||
|
IConsumer<RegistrationDeleted>
|
||||||
|
{
|
||||||
|
private readonly IMediator mediator;
|
||||||
|
|
||||||
|
public RegistrationsExcelEventsConsumer(IMediator mediator)
|
||||||
|
{
|
||||||
|
this.mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<RegistrationCreated> context)
|
||||||
|
{
|
||||||
|
return mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||||
|
context.Message.EventId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Consume(ConsumeContext<RegistrationUpdated> context)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||||
|
context.Message.EventId), context.CancellationToken);
|
||||||
|
|
||||||
|
if (context.Message.PreviousEventId != context.Message.EventId)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||||
|
context.Message.PreviousEventId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<RegistrationDeleted> context)
|
||||||
|
{
|
||||||
|
return mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||||
|
context.Message.EventId), context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Events.FilesAPI.Infrastructure.Options;
|
||||||
|
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||||
|
using Events.WebAPI.Handlers.EF.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
|
||||||
|
public sealed class RegistrationsExcelFileGenerator(
|
||||||
|
EventsContext context,
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
IOptions<GeneratedFilesOptions> generatedFilesOptions,
|
||||||
|
ILogger<RegistrationsExcelFileGenerator> logger)
|
||||||
|
{
|
||||||
|
public async Task GenerateAsync(int eventId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var registrations = context.Set<Registration>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.EventId == eventId)
|
||||||
|
.OrderBy(r => r.Person.LastName)
|
||||||
|
.ThenBy(r => r.Person.FirstName)
|
||||||
|
.ThenBy(r => r.Sport.Name)
|
||||||
|
.Select(r => new EventRegistrationsExcelWriter.RowData
|
||||||
|
{
|
||||||
|
RegistrationId = r.Id,
|
||||||
|
RegisteredAt = r.RegisteredAt,
|
||||||
|
PersonId = r.PersonId,
|
||||||
|
FirstName = r.Person.FirstName,
|
||||||
|
LastName = r.Person.LastName,
|
||||||
|
FirstNameTranscription = r.Person.FirstNameTranscription,
|
||||||
|
LastNameTranscription = r.Person.LastNameTranscription,
|
||||||
|
CountryName = r.Person.CountryCodeNavigation.Name,
|
||||||
|
SportName = r.Sport.Name
|
||||||
|
});
|
||||||
|
|
||||||
|
string excelPath = GetPath(eventId);
|
||||||
|
EventRegistrationsExcelWriter.RowData? firstRow = await registrations.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (firstRow == null)
|
||||||
|
{
|
||||||
|
DeleteFileIfExists(excelPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(excelPath)!);
|
||||||
|
await EventRegistrationsExcelWriter.WriteAsync(excelPath, registrations, firstRow, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Event registrations Excel generated for event #{EventId} at {Path}",
|
||||||
|
eventId,
|
||||||
|
excelPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPath(int eventId)
|
||||||
|
{
|
||||||
|
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||||
|
? generatedFilesOptions.Value.OutputPath
|
||||||
|
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||||
|
|
||||||
|
return Path.Combine(rootPath, $"{eventId}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteFileIfExists(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
File.Delete(path);
|
||||||
|
logger.LogInformation("Event registrations Excel deleted at {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
|
||||||
|
public sealed record SynchronizeRegistrationsExcelCommand(int EventId) : IRequest;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||||
|
|
||||||
|
public sealed class SynchronizeRegistrationsExcelHandler(
|
||||||
|
RegistrationsExcelFileGenerator generator) : IRequestHandler<SynchronizeRegistrationsExcelCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SynchronizeRegistrationsExcelCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await generator.GenerateAsync(request.EventId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Events.FilesAPI.Infrastructure.Files;
|
||||||
|
|
||||||
|
public sealed class GeneratedFileReference
|
||||||
|
{
|
||||||
|
public string PhysicalPath { get; init; } = string.Empty;
|
||||||
|
public string FileName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Events.FilesAPI.Features.Certificates;
|
||||||
|
using Events.FilesAPI.Features.RegistrationsExcel;
|
||||||
|
using MassTransit;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
public static class MassTransitSetupExtensions
|
||||||
|
{
|
||||||
|
public static void SetupMassTransit(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<RabbitMqSettings>()
|
||||||
|
.Bind(configuration.GetSection("RabbitMq"))
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.Validate(
|
||||||
|
settings => Uri.TryCreate(settings.Host, UriKind.Absolute, out var uri) &&
|
||||||
|
uri.Scheme == "rabbitmq" &&
|
||||||
|
!string.IsNullOrWhiteSpace(uri.Host),
|
||||||
|
"RabbitMq:Host must be a valid absolute rabbitmq:// URI.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddMassTransit(x =>
|
||||||
|
{
|
||||||
|
x.AddConsumer<CertificateRegistrationEventsConsumer>();
|
||||||
|
x.AddConsumer<RegistrationsExcelEventsConsumer>();
|
||||||
|
|
||||||
|
x.UsingRabbitMq((context, cfg) =>
|
||||||
|
{
|
||||||
|
var settings = context.GetRequiredService<IOptions<RabbitMqSettings>>().Value;
|
||||||
|
|
||||||
|
cfg.Host(new Uri(settings.Host), h =>
|
||||||
|
{
|
||||||
|
h.Username(settings.Username);
|
||||||
|
h.Password(settings.Password);
|
||||||
|
});
|
||||||
|
|
||||||
|
cfg.ReceiveEndpoint("events-filesapi-registration-changes", e =>
|
||||||
|
{
|
||||||
|
e.ConfigureConsumer<CertificateRegistrationEventsConsumer>(context);
|
||||||
|
e.ConfigureConsumer<RegistrationsExcelEventsConsumer>(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
public class RabbitMqSettings
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Events.FilesAPI.Infrastructure.Options;
|
||||||
|
|
||||||
|
public class GeneratedFilesOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string OutputPath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
32
Events-WebApi/Events.FilesAPI/Program.cs
Normal file
32
Events-WebApi/Events.FilesAPI/Program.cs
Normal 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();
|
||||||
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal file
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7296",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Events-WebApi/Events.FilesAPI/appsettings.json
Normal file
20
Events-WebApi/Events.FilesAPI/appsettings.json
Normal 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;"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Command;
|
||||||
|
|
||||||
|
public class AddCommand<TDto, TPK>(TDto dto) : IRequest<TPK>
|
||||||
|
{
|
||||||
|
public TDto Dto { get; set; } = dto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace MobilityOne.Common.Commands;
|
||||||
|
|
||||||
|
public class DeleteCommand<TDto, TPK>(TPK id) : IRequest
|
||||||
|
{
|
||||||
|
public TPK Id { get; set; } = id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Command;
|
||||||
|
|
||||||
|
public class UpdateCommand<TDto>(TDto dto) : IRequest
|
||||||
|
{
|
||||||
|
public TDto Dto { get; set; } = dto;
|
||||||
|
}
|
||||||
18
Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
Normal file
18
Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Sieve.Attributes;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public class EventDTO : IHasIdAsPK<int>
|
||||||
|
{
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public DateOnly EventDate { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public int RegistrationsCount { get; set; }
|
||||||
|
}
|
||||||
6
Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
Normal file
6
Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public interface IHasIdAsPK<T> where T : IEquatable<T>
|
||||||
|
{
|
||||||
|
T Id { get; }
|
||||||
|
}
|
||||||
10
Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
Normal file
10
Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public class IdName<T>
|
||||||
|
{
|
||||||
|
public T Id { get; set; } = default!;
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
13
Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
Normal file
13
Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains requested items (based on filter, paging and sorting criteria),
|
||||||
|
/// and the number of total items satisfying the filter
|
||||||
|
/// (or count of all items if no filter is present)
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
public class Items<T>
|
||||||
|
{
|
||||||
|
public List<T>? Data { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
52
Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
Normal file
52
Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Sieve.Attributes;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public class PersonDTO : IHasIdAsPK<int>
|
||||||
|
{
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string FirstNameTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string LastNameTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string AddressLine { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string City { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string AddressCountry { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ContactPhone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public DateOnly BirthDate { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string DocumentNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string CountryCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string CountryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public string FullNameTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public int RegistrationsCount { get; set; }
|
||||||
|
}
|
||||||
42
Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
Normal file
42
Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using Sieve.Attributes;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public class RegistrationDTO : IHasIdAsPK<int>
|
||||||
|
{
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public int EventId { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public int PersonId { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public int SportId { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public DateTime RegisteredAt { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string PersonName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true)]
|
||||||
|
public string PersonTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string PersonFirstNameTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string PersonLastNameTranscription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true)]
|
||||||
|
public string CountryCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public string CountryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string SportName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
12
Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
Normal file
12
Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Sieve.Attributes;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.DTOs;
|
||||||
|
|
||||||
|
public class SportDTO : IHasIdAsPK<int>
|
||||||
|
{
|
||||||
|
[Sieve(CanSort = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Sieve(CanFilter = true, CanSort = true)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||||
|
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||||
|
<PackageReference Include="Sieve" Version="2.5.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.LookupQueries;
|
||||||
|
|
||||||
|
public class LookupCountryQuery : IRequest<List<IdName<string>>>
|
||||||
|
{
|
||||||
|
public string? Text { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.LookupQueries;
|
||||||
|
|
||||||
|
public class LookupPeopleQuery : IRequest<List<IdName<int>>>
|
||||||
|
{
|
||||||
|
public string? Text { get; set; }
|
||||||
|
|
||||||
|
public string? CountryCode { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Events.WebAPI.Contract.Messages;
|
||||||
|
|
||||||
|
public record RegistrationCreated
|
||||||
|
{
|
||||||
|
public int RegistrationId { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public int EventId { get; init; }
|
||||||
|
public int SportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Events.WebAPI.Contract.Messages;
|
||||||
|
|
||||||
|
public record RegistrationDeleted
|
||||||
|
{
|
||||||
|
public int RegistrationId { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public int EventId { get; init; }
|
||||||
|
public int SportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Events.WebAPI.Contract.Messages;
|
||||||
|
|
||||||
|
public record RegistrationUpdated
|
||||||
|
{
|
||||||
|
public int RegistrationId { get; init; }
|
||||||
|
public int PersonId { get; init; }
|
||||||
|
public int EventId { get; init; }
|
||||||
|
public int SportId { get; init; }
|
||||||
|
public int PreviousPersonId { get; init; }
|
||||||
|
public int PreviousEventId { get; init; }
|
||||||
|
public int PreviousSportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
|
||||||
|
public class DoesItemExistsQuery<TDto, TPK> (TPK id) : IRequest<bool>, IHasIdAsPK<TPK>
|
||||||
|
where TPK : IEquatable<TPK>
|
||||||
|
{
|
||||||
|
public TPK Id { get; set; } = id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
|
||||||
|
public abstract class GetCountQuery : IRequest<int>
|
||||||
|
{
|
||||||
|
public string? Filters { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetCountQuery<TDto> : GetCountQuery
|
||||||
|
{
|
||||||
|
public static GetCountQuery<TDto> CreateForPK<TPK>(TPK id) where TPK : IEquatable<TPK> {
|
||||||
|
var query = new GetCountQuery<TDto>()
|
||||||
|
{
|
||||||
|
Filters = $"id=={id}"
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
|
||||||
|
public class GetItemsQuery<TDto> : IRequest<List<TDto>>
|
||||||
|
{
|
||||||
|
public string? Filters { get; set; }
|
||||||
|
public string? Sort { get; set; }
|
||||||
|
public bool Ascending { get; set; }
|
||||||
|
public int? PageSize { get; set; }
|
||||||
|
public int? Page { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
|
||||||
|
public class GetSingleItemQuery<TDto, TPK>(TPK id) : IRequest<TDto>, IHasIdAsPK<TPK>
|
||||||
|
where TPK : IEquatable<TPK>
|
||||||
|
{
|
||||||
|
public TPK Id { get; set; } = id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Events.WebAPI.Contract.Command;
|
||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Validation.Event;
|
||||||
|
|
||||||
|
public class AddEventValidator : AbstractValidator<AddCommand<EventDTO, int>>
|
||||||
|
{
|
||||||
|
public AddEventValidator()
|
||||||
|
{
|
||||||
|
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
|
||||||
|
RuleFor(a => a.Dto.EventDate).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using MobilityOne.Common.Commands;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Validation.Event;
|
||||||
|
|
||||||
|
public class DeleteEventValidator : AbstractValidator<DeleteCommand<EventDTO, int>>
|
||||||
|
{
|
||||||
|
public DeleteEventValidator(IMediator mediator)
|
||||||
|
{
|
||||||
|
RuleFor(a => a.Id).NoChildRecords<DeleteCommand<EventDTO, int>, RegistrationDTO, int>(nameof(RegistrationDTO.EventId), mediator);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Events.WebAPI.Contract.Command;
|
||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Validation.Event;
|
||||||
|
|
||||||
|
public class UpdateEventValidator : AbstractValidator<UpdateCommand<EventDTO>>
|
||||||
|
{
|
||||||
|
public UpdateEventValidator()
|
||||||
|
{
|
||||||
|
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
|
||||||
|
RuleFor(a => a.Dto.EventDate).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Events.WebAPI.Contract.DTOs;
|
||||||
|
using Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Validation;
|
||||||
|
|
||||||
|
public static class ForeignKeyValueValidatorExtension
|
||||||
|
{
|
||||||
|
public static IRuleBuilderOptions<TCommand, TPK> ForeignKeyExists<TCommand, TDto, TPK>(
|
||||||
|
this IRuleBuilder<TCommand, TPK> ruleBuilder,
|
||||||
|
IMediator mediator,
|
||||||
|
IValidationMessageProvider validationMessageProvider,
|
||||||
|
ValidationMessage? validationMessage = null)
|
||||||
|
where TDto : IHasIdAsPK<TPK>
|
||||||
|
where TPK : IEquatable<TPK>
|
||||||
|
{
|
||||||
|
ValidationMessage message = validationMessage ?? validationMessageProvider.ForeignKeyNotFound("{PropertyName}");
|
||||||
|
|
||||||
|
return ruleBuilder.MustAsync(new ForeignKeyValueValidator<TCommand, TDto, TPK>(mediator).Validate)
|
||||||
|
.WithMessage(message.Message)
|
||||||
|
.WithErrorCode(message.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ForeignKeyValueValidator<TCommand, TDto, TPK> where TDto : IHasIdAsPK<TPK> where TPK : IEquatable<TPK>
|
||||||
|
{
|
||||||
|
private readonly IMediator mediator;
|
||||||
|
|
||||||
|
public ForeignKeyValueValidator(IMediator mediator)
|
||||||
|
{
|
||||||
|
this.mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Validate(TCommand command, TPK value, ValidationContext<TCommand> validationContext, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new DoesItemExistsQuery<TDto, TPK>(value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool itemExists = await mediator.Send(query, cancellationToken);
|
||||||
|
return itemExists;
|
||||||
|
}
|
||||||
|
catch (Exception exc)
|
||||||
|
{
|
||||||
|
validationContext.AddFailure(exc.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Events.WebAPI.Contract.Validation;
|
||||||
|
|
||||||
|
public interface IValidationMessageProvider
|
||||||
|
{
|
||||||
|
ValidationMessage UniqueSportName(string sportName);
|
||||||
|
ValidationMessage UniquePersonDocumentAndCountry();
|
||||||
|
ValidationMessage PersonEmailOrContactPhoneRequired();
|
||||||
|
ValidationMessage UniqueRegistration();
|
||||||
|
ValidationMessage EventNotFound();
|
||||||
|
ValidationMessage PersonNotFound();
|
||||||
|
ValidationMessage SportNotFound();
|
||||||
|
ValidationMessage ForeignKeyNotFound(string propertyName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Events.WebAPI.Contract.Queries.Generic;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Events.WebAPI.Contract.Validation;
|
||||||
|
|
||||||
|
public static class NoChildRecordsValidatorExtension
|
||||||
|
{
|
||||||
|
public static IRuleBuilderOptions<TCommand, TPK> NoChildRecords<TCommand, TDto, TPK>(this IRuleBuilder<TCommand, TPK> ruleBuilder, string columnName, IMediator mediator)
|
||||||
|
{
|
||||||
|
return ruleBuilder.MustAsync(new NoChildRecordsValidator<TDto, TPK>(columnName, mediator).Validate)
|
||||||
|
.WithMessage("Cannot delete entity {PropertyValue} because there are child records in table related to " + typeof(TDto).Name.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoChildRecordsValidator<TDto, TPK>
|
||||||
|
{
|
||||||
|
private readonly string columnName;
|
||||||
|
private readonly IMediator mediator;
|
||||||
|
|
||||||
|
public NoChildRecordsValidator(string columnName, IMediator mediator)
|
||||||
|
{
|
||||||
|
this.columnName = columnName;
|
||||||
|
this.mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Validate(TPK value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetCountQuery<TDto>()
|
||||||
|
{
|
||||||
|
Filters = $"{columnName}=={value}"
|
||||||
|
};
|
||||||
|
int count = await mediator.Send(query, cancellationToken);
|
||||||
|
return count == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Events.WebAPI.Contract.Validation;
|
||||||
|
|
||||||
|
public sealed record ValidationMessage(string Code, string Message);
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user