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