diff --git a/Events-WebApi/Events-WebApi.slnx b/Events-WebApi/Events-WebApi.slnx
new file mode 100644
index 0000000..196d958
--- /dev/null
+++ b/Events-WebApi/Events-WebApi.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/.env.example b/Events-WebApi/Events.ClientApp/.env.example
new file mode 100644
index 0000000..c8ff118
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/.env.example
@@ -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
diff --git a/Events-WebApi/Events.ClientApp/README.md b/Events-WebApi/Events.ClientApp/README.md
new file mode 100644
index 0000000..8ff0414
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/README.md
@@ -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
diff --git a/Events-WebApi/Events.ClientApp/index.html b/Events-WebApi/Events.ClientApp/index.html
new file mode 100644
index 0000000..d00c179
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Events Client
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/package-lock.json b/Events-WebApi/Events.ClientApp/package-lock.json
new file mode 100644
index 0000000..6b5ff74
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/package-lock.json
@@ -0,0 +1,1753 @@
+{
+ "name": "events-clientapp",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "events-clientapp",
+ "version": "0.0.1",
+ "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"
+ }
+ },
+ "node_modules/@auth0/auth0-auth-js": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.6.0.tgz",
+ "integrity": "sha512-/WYYNlsqhWA6I60pMVLFVeOgjOUCLdJThEAsjN8pAgYY09BTxbPaRIEVDgGu6ckoJpkmKvEYlHPO/vwRNrvX6w==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.0.8",
+ "openid-client": "^6.8.0"
+ }
+ },
+ "node_modules/@auth0/auth0-spa-js": {
+ "version": "2.18.3",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.18.3.tgz",
+ "integrity": "sha512-nfZxRj+bq0t4dJfem7V0VK/mPjD9TTvu6Wd87Yc/k7QojiFf5VswDL1+9o+6WjXAIaIEttS6BLZUYcsIgphLiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@auth0/auth0-auth-js": "1.6.0",
+ "browser-tabs-lock": "1.3.0",
+ "dpop": "2.1.1",
+ "es-cookie": "1.3.2"
+ }
+ },
+ "node_modules/@auth0/auth0-vue": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-vue/-/auth0-vue-2.5.0.tgz",
+ "integrity": "sha512-/qHTW64QjHCbsVFKhbJq9/ZzpEsp35sFiQaWzH/J4z+lPldgXQMmEF6yvvWhg0TbXQHMXPgOqGnK9lLdRHkR3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@auth0/auth0-spa-js": "^2.10.0",
+ "vue": "^3.5.21"
+ },
+ "peerDependencies": {
+ "vue-router": "^4.0.12"
+ },
+ "peerDependenciesMeta": {
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@primeuix/styled": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.7.4.tgz",
+ "integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/utils": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=12.11.0"
+ }
+ },
+ "node_modules/@primeuix/styles": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-2.0.3.tgz",
+ "integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/styled": "^0.7.4"
+ }
+ },
+ "node_modules/@primeuix/themes": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-2.0.3.tgz",
+ "integrity": "sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/styled": "^0.7.4"
+ }
+ },
+ "node_modules/@primeuix/utils": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.6.4.tgz",
+ "integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.11.0"
+ }
+ },
+ "node_modules/@primevue/core": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.5.4.tgz",
+ "integrity": "sha512-lYJJB3wTrDJ8MkLctzHfrPZAqXVxoatjIsswSJzupatf6ZogJHVYADUKcn1JAkLLk8dtV1FA2AxDek663fHO5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/styled": "^0.7.4",
+ "@primeuix/utils": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=12.11.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@primevue/icons": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.5.4.tgz",
+ "integrity": "sha512-DxgryEc7ZmUqcEhYMcxGBRyFzdtLIoy3jLtlH1zsVSRZaG+iSAcjQ88nvfkZxGUZtZBFL7sRjF6KLq3bJZJwUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/utils": "^0.6.2",
+ "@primevue/core": "4.5.4"
+ },
+ "engines": {
+ "node": ">=12.11.0"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.15",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
+ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz",
+ "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/shared": "3.5.31",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz",
+ "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.31",
+ "@vue/shared": "3.5.31"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz",
+ "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/compiler-core": "3.5.31",
+ "@vue/compiler-dom": "3.5.31",
+ "@vue/compiler-ssr": "3.5.31",
+ "@vue/shared": "3.5.31",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.8",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz",
+ "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.31",
+ "@vue/shared": "3.5.31"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz",
+ "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.31"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz",
+ "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.31",
+ "@vue/shared": "3.5.31"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz",
+ "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.31",
+ "@vue/runtime-core": "3.5.31",
+ "@vue/shared": "3.5.31",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz",
+ "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.31",
+ "@vue/shared": "3.5.31"
+ },
+ "peerDependencies": {
+ "vue": "3.5.31"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
+ "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
+ "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/browser-tabs-lock": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
+ "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": ">=4.17.21"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dpop": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
+ "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-cookie": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
+ "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oauth4webapi": {
+ "version": "3.8.5",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
+ "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "6.8.2",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
+ "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.1.3",
+ "oauth4webapi": "^3.8.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/primeicons": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
+ "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
+ "license": "MIT"
+ },
+ "node_modules/primevue": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.5.4.tgz",
+ "integrity": "sha512-nTyEohZABFJhVIpeUxgP0EJ8vKcJAhD+Z7DYj95e7ie/MNUCjRNcGjqmE1cXtXi4z54qDfTSI9h2uJ51qz2DIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@primeuix/styled": "^0.7.4",
+ "@primeuix/styles": "^2.0.2",
+ "@primeuix/utils": "^0.6.2",
+ "@primevue/core": "4.5.4",
+ "@primevue/icons": "4.5.4"
+ },
+ "engines": {
+ "node": ">=12.11.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.31",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
+ "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.31",
+ "@vue/compiler-sfc": "3.5.31",
+ "@vue/runtime-dom": "3.5.31",
+ "@vue/server-renderer": "3.5.31",
+ "@vue/shared": "3.5.31"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/Events-WebApi/Events.ClientApp/package.json b/Events-WebApi/Events.ClientApp/package.json
new file mode 100644
index 0000000..b8103dc
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/package.json
@@ -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"
+ }
+}
diff --git a/Events-WebApi/Events.ClientApp/src/App.vue b/Events-WebApi/Events.ClientApp/src/App.vue
new file mode 100644
index 0000000..282473d
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/App.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Events
+
Error: {{ error.message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/src/api/eventsApi.ts b/Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
new file mode 100644
index 0000000..27824a5
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
@@ -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>('/Sports', toQuery(pageState)),
+ get: (id: number) => getJson(`/Sports/${id}`),
+ create: (payload: SportDto) => postJson('/Sports', payload),
+ update: (payload: SportDto) => putJson(`/Sports/${payload.id}`, payload),
+ remove: (id: number) => deleteJson(`/Sports/${id}`)
+};
+
+export const eventsApi = {
+ list: (pageState: PageState) => getJson>('/Events', toQuery(pageState)),
+ get: (id: number) => getJson(`/Events/${id}`),
+ create: (payload: EventDto) => postJson('/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>('/People', toQuery(pageState)),
+ get: (id: number) => getJson(`/People/${id}`),
+ create: (payload: PersonDto) => postJson('/People', payload),
+ update: (payload: PersonDto) => putJson(`/People/${payload.id}`, payload),
+ remove: (id: number) => deleteJson(`/People/${id}`)
+};
+
+export const registrationsApi = {
+ list: (pageState: PageState) => getJson>('/Registrations', toQuery(pageState)),
+ get: (id: number) => getJson(`/Registrations/${id}`),
+ create: (payload: RegistrationUpsertDto) => postJson('/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>>('/Lookup/Countries', { text }),
+ people: (text?: string, countryCode?: string) =>
+ getJson>>('/Lookup/People', { text, countryCode })
+};
diff --git a/Events-WebApi/Events.ClientApp/src/api/http.ts b/Events-WebApi/Events.ClientApp/src/api/http.ts
new file mode 100644
index 0000000..30b0dc4
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/api/http.ts
@@ -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(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).map(([key, entryValue]) => [
+ toCamelCase(key),
+ normalizeJson(entryValue)
+ ]);
+
+ return Object.fromEntries(normalizedEntries) as T;
+ }
+
+ return value;
+}
+
+function buildUrl(
+ path: string,
+ query?: Record,
+ 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(response: Response): Promise {
+ 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(path: string, query?: Record) {
+ const response = await fetch(buildUrl(path, query), {
+ headers: await buildAuthHeaders()
+ });
+ return parseResponse(response);
+}
+
+export async function postJson(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(response);
+}
+
+export async function putJson(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(response);
+}
+
+export async function deleteJson(path: string) {
+ const response = await fetch(buildUrl(path), {
+ method: 'DELETE',
+ headers: await buildAuthHeaders()
+ });
+
+ return parseResponse(response);
+}
+
+export async function getFile(
+ path: string,
+ query?: Record
+) {
+ const response = await fetch(buildUrl(path, query, filesApiBaseUrl), {
+ headers: await buildAuthHeaders()
+ });
+
+ if (!response.ok) {
+ await parseResponse(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'
+ };
+}
diff --git a/Events-WebApi/Events.ClientApp/src/api/types.ts b/Events-WebApi/Events.ClientApp/src/api/types.ts
new file mode 100644
index 0000000..24d09b6
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/api/types.ts
@@ -0,0 +1,79 @@
+export interface ItemsResponse {
+ data: T[] | null;
+ count: number;
+}
+
+export interface IdName {
+ 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;
+ errorCodes?: Record;
+}
+
+export interface PageState {
+ page: number;
+ pageSize: number;
+ sort?: string;
+ sortOrder?: 1 | -1;
+ filters?: string;
+}
diff --git a/Events-WebApi/Events.ClientApp/src/auth.ts b/Events-WebApi/Events.ClientApp/src/auth.ts
new file mode 100644
index 0000000..0e3069c
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/auth.ts
@@ -0,0 +1,19 @@
+import { createAuth0 } from '@auth0/auth0-vue';
+
+const authorizationParams: Record = {
+ 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
+});
diff --git a/Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue b/Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
new file mode 100644
index 0000000..7fd33f2
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
@@ -0,0 +1,319 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDateOnly(data.eventDate) }}
+
+
+
+
+
+
+
+
+ {{ data.registrationsCount ?? 0 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+ Date
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue b/Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
new file mode 100644
index 0000000..d78dad3
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
@@ -0,0 +1,431 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDateOnly(data.birthDate) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
First name
+
Last name
+
First name transcription
+
Last name transcription
+
Address
+
Postal code
+
City
+
Address country
+
Email
+
Phone
+
Birth date
+
Document number
+
+ Person country
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/src/components/RegistrationsPanel.vue b/Events-WebApi/Events.ClientApp/src/components/RegistrationsPanel.vue
new file mode 100644
index 0000000..7cd8822
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/components/RegistrationsPanel.vue
@@ -0,0 +1,523 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ events.find((item) => item.id === data.eventId)?.name || data.eventId }}
+
+
+
+
+
+
+
+ {{ formatDateTime(data.registeredAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Event
+
+
+
+ Sport
+
+
+
+
Person
+
+
+
+ {{ option.name }}
+ {{ option.description || `ID: ${option.id}` }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue b/Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
new file mode 100644
index 0000000..98ab477
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.ClientApp/src/env.d.ts b/Events-WebApi/Events.ClientApp/src/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/Events-WebApi/Events.ClientApp/src/main.ts b/Events-WebApi/Events.ClientApp/src/main.ts
new file mode 100644
index 0000000..5b5baed
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/main.ts
@@ -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');
diff --git a/Events-WebApi/Events.ClientApp/src/state/catalogState.ts b/Events-WebApi/Events.ClientApp/src/state/catalogState.ts
new file mode 100644
index 0000000..eb9bda1
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/state/catalogState.ts
@@ -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;
+}
diff --git a/Events-WebApi/Events.ClientApp/src/state/uiState.ts b/Events-WebApi/Events.ClientApp/src/state/uiState.ts
new file mode 100644
index 0000000..c7196ab
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/state/uiState.ts
@@ -0,0 +1,15 @@
+import { ref } from 'vue';
+
+export const activeTab = ref('sports');
+export const pendingRegistrationEventId = ref(null);
+
+export function openRegistrationCreateForEvent(eventId: number) {
+ pendingRegistrationEventId.value = eventId;
+ activeTab.value = 'registrations';
+}
+
+export function consumePendingRegistrationEventId() {
+ const eventId = pendingRegistrationEventId.value;
+ pendingRegistrationEventId.value = null;
+ return eventId;
+}
diff --git a/Events-WebApi/Events.ClientApp/src/style.css b/Events-WebApi/Events.ClientApp/src/style.css
new file mode 100644
index 0000000..b91d0ab
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/style.css
@@ -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;
+ }
+}
diff --git a/Events-WebApi/Events.ClientApp/src/utils/dates.ts b/Events-WebApi/Events.ClientApp/src/utils/dates.ts
new file mode 100644
index 0000000..dffb3e1
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/src/utils/dates.ts
@@ -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));
+}
diff --git a/Events-WebApi/Events.ClientApp/tsconfig.app.json b/Events-WebApi/Events.ClientApp/tsconfig.app.json
new file mode 100644
index 0000000..10c2fa2
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/tsconfig.app.json
@@ -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"]
+}
diff --git a/Events-WebApi/Events.ClientApp/tsconfig.json b/Events-WebApi/Events.ClientApp/tsconfig.json
new file mode 100644
index 0000000..426eda2
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" }
+ ]
+}
diff --git a/Events-WebApi/Events.ClientApp/vite.config.ts b/Events-WebApi/Events.ClientApp/vite.config.ts
new file mode 100644
index 0000000..5cbb231
--- /dev/null
+++ b/Events-WebApi/Events.ClientApp/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ port: 5173
+ }
+});
diff --git a/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj b/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
new file mode 100644
index 0000000..b0c8e66
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+ PI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfDocumentWriter.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfDocumentWriter.cs
new file mode 100644
index 0000000..fbe7215
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfDocumentWriter.cs
@@ -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 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 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();
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfModel.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfModel.cs
new file mode 100644
index 0000000..8b21f8e
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificatePdfModel.cs
@@ -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 SportNames { get; init; } = Array.Empty();
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificateRegistrationEventsConsumer.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificateRegistrationEventsConsumer.cs
new file mode 100644
index 0000000..a949133
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/CertificateRegistrationEventsConsumer.cs
@@ -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,
+ IConsumer,
+ IConsumer
+{
+ private readonly IMediator mediator;
+
+ public CertificateRegistrationEventsConsumer(IMediator mediator)
+ {
+ this.mediator = mediator;
+ }
+
+ public Task Consume(ConsumeContext context)
+ {
+ return mediator.Send(new SynchronizeCertificateCommand(
+ context.Message.EventId,
+ context.Message.PersonId), context.CancellationToken);
+ }
+
+ public async Task Consume(ConsumeContext 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 context)
+ {
+ return mediator.Send(new SynchronizeCertificateCommand(
+ context.Message.EventId,
+ context.Message.PersonId), context.CancellationToken);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/CertificateFileLocator.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/CertificateFileLocator.cs
new file mode 100644
index 0000000..fcce27c
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/CertificateFileLocator.cs
@@ -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)
+{
+ 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
+ };
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateHandler.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateHandler.cs
new file mode 100644
index 0000000..f08de4d
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateHandler.cs
@@ -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
+{
+ public async Task 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);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateQuery.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateQuery.cs
new file mode 100644
index 0000000..c0bf2fe
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Download/DownloadCertificateQuery.cs
@@ -0,0 +1,8 @@
+using Events.FilesAPI.Infrastructure.Files;
+using MediatR;
+
+namespace Events.FilesAPI.Features.Certificates.Download;
+
+public sealed record DownloadCertificateQuery(int RegistrationId) : IRequest;
+
+public sealed record DownloadCertificateResult(bool RegistrationFound, GeneratedFileReference? File);
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs
new file mode 100644
index 0000000..f7268b4
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs
@@ -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 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);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/CertificateFileGenerator.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/CertificateFileGenerator.cs
new file mode 100644
index 0000000..57c2f20
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/CertificateFileGenerator.cs
@@ -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,
+ ILogger logger)
+{
+ public async Task GenerateAsync(int eventId, int personId, CancellationToken cancellationToken)
+ {
+ var registrations = await context.Set()
+ .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 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;
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateCommand.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateCommand.cs
new file mode 100644
index 0000000..536fcbd
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateCommand.cs
@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace Events.FilesAPI.Features.Certificates.Synchronize;
+
+public sealed record SynchronizeCertificateCommand(int EventId, int PersonId) : IRequest;
diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateHandler.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateHandler.cs
new file mode 100644
index 0000000..feb41fe
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/Synchronize/SynchronizeCertificateHandler.cs
@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Events.FilesAPI.Features.Certificates.Synchronize;
+
+public sealed class SynchronizeCertificateHandler(
+ CertificateFileGenerator generator) : IRequestHandler
+{
+ public async Task Handle(SynchronizeCertificateCommand request, CancellationToken cancellationToken)
+ {
+ await generator.GenerateAsync(request.EventId, request.PersonId, cancellationToken);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelHandler.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelHandler.cs
new file mode 100644
index 0000000..a8ac8b7
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelHandler.cs
@@ -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
+{
+ public async Task 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);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelQuery.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelQuery.cs
new file mode 100644
index 0000000..a3bf9f9
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/DownloadRegistrationsExcelQuery.cs
@@ -0,0 +1,8 @@
+using Events.FilesAPI.Infrastructure.Files;
+using MediatR;
+
+namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
+
+public sealed record DownloadRegistrationsExcelQuery(int EventId) : IRequest;
+
+public sealed record DownloadRegistrationsExcelResult(bool EventFound, GeneratedFileReference? File);
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/RegistrationsExcelFileLocator.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/RegistrationsExcelFileLocator.cs
new file mode 100644
index 0000000..ad7d706
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Download/RegistrationsExcelFileLocator.cs
@@ -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)
+{
+ 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
+ };
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs
new file mode 100644
index 0000000..eccdfda
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs
@@ -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 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);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/EventRegistrationsExcelWriter.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/EventRegistrationsExcelWriter.cs
new file mode 100644
index 0000000..7336787
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/EventRegistrationsExcelWriter.cs
@@ -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 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;
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/RegistrationsExcelEventsConsumer.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/RegistrationsExcelEventsConsumer.cs
new file mode 100644
index 0000000..f5c197a
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/RegistrationsExcelEventsConsumer.cs
@@ -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,
+ IConsumer,
+ IConsumer
+{
+ private readonly IMediator mediator;
+
+ public RegistrationsExcelEventsConsumer(IMediator mediator)
+ {
+ this.mediator = mediator;
+ }
+
+ public Task Consume(ConsumeContext context)
+ {
+ return mediator.Send(new SynchronizeRegistrationsExcelCommand(
+ context.Message.EventId), context.CancellationToken);
+ }
+
+ public async Task Consume(ConsumeContext 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 context)
+ {
+ return mediator.Send(new SynchronizeRegistrationsExcelCommand(
+ context.Message.EventId), context.CancellationToken);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/RegistrationsExcelFileGenerator.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/RegistrationsExcelFileGenerator.cs
new file mode 100644
index 0000000..4c455b8
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/RegistrationsExcelFileGenerator.cs
@@ -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,
+ ILogger logger)
+{
+ public async Task GenerateAsync(int eventId, CancellationToken cancellationToken)
+ {
+ var registrations = context.Set()
+ .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);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelCommand.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelCommand.cs
new file mode 100644
index 0000000..43290dc
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelCommand.cs
@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
+
+public sealed record SynchronizeRegistrationsExcelCommand(int EventId) : IRequest;
diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelHandler.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelHandler.cs
new file mode 100644
index 0000000..d42f061
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/Synchronize/SynchronizeRegistrationsExcelHandler.cs
@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
+
+public sealed class SynchronizeRegistrationsExcelHandler(
+ RegistrationsExcelFileGenerator generator) : IRequestHandler
+{
+ public async Task Handle(SynchronizeRegistrationsExcelCommand request, CancellationToken cancellationToken)
+ {
+ await generator.GenerateAsync(request.EventId, cancellationToken);
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Infrastructure/Files/GeneratedFileReference.cs b/Events-WebApi/Events.FilesAPI/Infrastructure/Files/GeneratedFileReference.cs
new file mode 100644
index 0000000..d68610a
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Infrastructure/Files/GeneratedFileReference.cs
@@ -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;
+}
diff --git a/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/MassTransitSetupExtensions.cs b/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/MassTransitSetupExtensions.cs
new file mode 100644
index 0000000..087da5e
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/MassTransitSetupExtensions.cs
@@ -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()
+ .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();
+ x.AddConsumer();
+
+ x.UsingRabbitMq((context, cfg) =>
+ {
+ var settings = context.GetRequiredService>().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(context);
+ e.ConfigureConsumer(context);
+ });
+ });
+ });
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/RabbitMqSettings.cs b/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/RabbitMqSettings.cs
new file mode 100644
index 0000000..b1b1686
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Infrastructure/Messaging/RabbitMqSettings.cs
@@ -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;
+}
diff --git a/Events-WebApi/Events.FilesAPI/Infrastructure/Options/GeneratedFilesOptions.cs b/Events-WebApi/Events.FilesAPI/Infrastructure/Options/GeneratedFilesOptions.cs
new file mode 100644
index 0000000..765011e
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Infrastructure/Options/GeneratedFilesOptions.cs
@@ -0,0 +1,9 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Events.FilesAPI.Infrastructure.Options;
+
+public class GeneratedFilesOptions
+{
+ [Required]
+ public string OutputPath { get; set; } = string.Empty;
+}
diff --git a/Events-WebApi/Events.FilesAPI/Program.cs b/Events-WebApi/Events.FilesAPI/Program.cs
new file mode 100644
index 0000000..f6af075
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Program.cs
@@ -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(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB")));
+
+builder.Services.AddOptions()
+ .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();
diff --git a/Events-WebApi/Events.FilesAPI/Properties/launchSettings.json b/Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
new file mode 100644
index 0000000..98a0b55
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/appsettings.Development.json b/Events-WebApi/Events.FilesAPI/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Events-WebApi/Events.FilesAPI/appsettings.json b/Events-WebApi/Events.FilesAPI/appsettings.json
new file mode 100644
index 0000000..ec009c6
--- /dev/null
+++ b/Events-WebApi/Events.FilesAPI/appsettings.json
@@ -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;"
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Commands/AddCommand.cs b/Events-WebApi/Events.WebAPI.Contract/Commands/AddCommand.cs
new file mode 100644
index 0000000..7818ecf
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Commands/AddCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace Events.WebAPI.Contract.Command;
+
+public class AddCommand(TDto dto) : IRequest
+{
+ public TDto Dto { get; set; } = dto;
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Contract/Commands/DeleteCommand.cs b/Events-WebApi/Events.WebAPI.Contract/Commands/DeleteCommand.cs
new file mode 100644
index 0000000..d25b7c5
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Commands/DeleteCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace MobilityOne.Common.Commands;
+
+public class DeleteCommand(TPK id) : IRequest
+{
+ public TPK Id { get; set; } = id;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Commands/UpdateCommand.cs b/Events-WebApi/Events.WebAPI.Contract/Commands/UpdateCommand.cs
new file mode 100644
index 0000000..bc6cc7a
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Commands/UpdateCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace Events.WebAPI.Contract.Command;
+
+public class UpdateCommand(TDto dto) : IRequest
+{
+ public TDto Dto { get; set; } = dto;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
new file mode 100644
index 0000000..ba849a1
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/EventDTO.cs
@@ -0,0 +1,18 @@
+using Sieve.Attributes;
+
+namespace Events.WebAPI.Contract.DTOs;
+
+public class EventDTO : IHasIdAsPK
+{
+ [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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
new file mode 100644
index 0000000..e86c333
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/IHasIdAsPK.cs
@@ -0,0 +1,6 @@
+namespace Events.WebAPI.Contract.DTOs;
+
+public interface IHasIdAsPK where T : IEquatable
+{
+ T Id { get; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
new file mode 100644
index 0000000..6db4076
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/IdName.cs
@@ -0,0 +1,10 @@
+namespace Events.WebAPI.Contract.DTOs;
+
+public class IdName
+{
+ public T Id { get; set; } = default!;
+
+ public string Name { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
new file mode 100644
index 0000000..8abacfa
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/Items.cs
@@ -0,0 +1,13 @@
+namespace Events.WebAPI.Contract.DTOs;
+
+///
+/// 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)
+///
+///
+public class Items
+{
+ public List? Data { get; set; }
+ public int Count { get; set; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
new file mode 100644
index 0000000..236f283
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/PersonDTO.cs
@@ -0,0 +1,52 @@
+using Sieve.Attributes;
+
+namespace Events.WebAPI.Contract.DTOs;
+
+public class PersonDTO : IHasIdAsPK
+{
+ [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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
new file mode 100644
index 0000000..0c9e39c
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/RegistrationDTO.cs
@@ -0,0 +1,42 @@
+using Sieve.Attributes;
+
+namespace Events.WebAPI.Contract.DTOs;
+
+public class RegistrationDTO : IHasIdAsPK
+{
+ [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;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs b/Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
new file mode 100644
index 0000000..16d0ebb
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/DTOs/SportDTO.cs
@@ -0,0 +1,12 @@
+using Sieve.Attributes;
+
+namespace Events.WebAPI.Contract.DTOs;
+
+public class SportDTO : IHasIdAsPK
+{
+ [Sieve(CanSort = true)]
+ public int Id { get; set; }
+
+ [Sieve(CanFilter = true, CanSort = true)]
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Events.WebAPI.Contract.csproj b/Events-WebApi/Events.WebAPI.Contract/Events.WebAPI.Contract.csproj
new file mode 100644
index 0000000..231e820
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Events.WebAPI.Contract.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupCountryQuery.cs b/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupCountryQuery.cs
new file mode 100644
index 0000000..9cf3209
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupCountryQuery.cs
@@ -0,0 +1,9 @@
+using Events.WebAPI.Contract.DTOs;
+using MediatR;
+
+namespace Events.WebAPI.Contract.LookupQueries;
+
+public class LookupCountryQuery : IRequest>>
+{
+ public string? Text { get; set; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupPeopleQuery.cs b/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupPeopleQuery.cs
new file mode 100644
index 0000000..44aca85
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/LookupQueries/LookupPeopleQuery.cs
@@ -0,0 +1,11 @@
+using Events.WebAPI.Contract.DTOs;
+using MediatR;
+
+namespace Events.WebAPI.Contract.LookupQueries;
+
+public class LookupPeopleQuery : IRequest>>
+{
+ public string? Text { get; set; }
+
+ public string? CountryCode { get; set; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationCreated.cs b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationCreated.cs
new file mode 100644
index 0000000..9e002d0
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationCreated.cs
@@ -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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationDeleted.cs b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationDeleted.cs
new file mode 100644
index 0000000..3c15863
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationDeleted.cs
@@ -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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationUpdated.cs b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationUpdated.cs
new file mode 100644
index 0000000..3609e52
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Messages/RegistrationUpdated.cs
@@ -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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/DoesItemExistsQuery.cs b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/DoesItemExistsQuery.cs
new file mode 100644
index 0000000..2f401a4
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/DoesItemExistsQuery.cs
@@ -0,0 +1,10 @@
+using Events.WebAPI.Contract.DTOs;
+using MediatR;
+
+namespace Events.WebAPI.Contract.Queries.Generic;
+
+public class DoesItemExistsQuery (TPK id) : IRequest, IHasIdAsPK
+ where TPK : IEquatable
+{
+ public TPK Id { get; set; } = id;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetCountQuery.cs b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetCountQuery.cs
new file mode 100644
index 0000000..264b4f1
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetCountQuery.cs
@@ -0,0 +1,19 @@
+using MediatR;
+
+namespace Events.WebAPI.Contract.Queries.Generic;
+
+public abstract class GetCountQuery : IRequest
+{
+ public string? Filters { get; set; }
+}
+
+public class GetCountQuery : GetCountQuery
+{
+ public static GetCountQuery CreateForPK(TPK id) where TPK : IEquatable {
+ var query = new GetCountQuery()
+ {
+ Filters = $"id=={id}"
+ };
+ return query;
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetItemsQuery.cs b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetItemsQuery.cs
new file mode 100644
index 0000000..421dd40
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetItemsQuery.cs
@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Events.WebAPI.Contract.Queries.Generic;
+
+public class GetItemsQuery : IRequest>
+{
+ 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; }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetSingleItemQuery.cs b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetSingleItemQuery.cs
new file mode 100644
index 0000000..729937b
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Queries/Generic/GetSingleItemQuery.cs
@@ -0,0 +1,10 @@
+using Events.WebAPI.Contract.DTOs;
+using MediatR;
+
+namespace Events.WebAPI.Contract.Queries.Generic;
+
+public class GetSingleItemQuery(TPK id) : IRequest, IHasIdAsPK
+ where TPK : IEquatable
+{
+ public TPK Id { get; set; } = id;
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Event/AddEventValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/AddEventValidator.cs
new file mode 100644
index 0000000..5d3f154
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/AddEventValidator.cs
@@ -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>
+{
+ public AddEventValidator()
+ {
+ RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
+ RuleFor(a => a.Dto.EventDate).NotEmpty();
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Event/DeleteEventValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/DeleteEventValidator.cs
new file mode 100644
index 0000000..4aae791
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/DeleteEventValidator.cs
@@ -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>
+{
+ public DeleteEventValidator(IMediator mediator)
+ {
+ RuleFor(a => a.Id).NoChildRecords, RegistrationDTO, int>(nameof(RegistrationDTO.EventId), mediator);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Event/UpdateEventValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/UpdateEventValidator.cs
new file mode 100644
index 0000000..057d866
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Event/UpdateEventValidator.cs
@@ -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>
+{
+ public UpdateEventValidator()
+ {
+ RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
+ RuleFor(a => a.Dto.EventDate).NotEmpty();
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/ForeignKeyValueValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/ForeignKeyValueValidator.cs
new file mode 100644
index 0000000..5cd8961
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/ForeignKeyValueValidator.cs
@@ -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 ForeignKeyExists(
+ this IRuleBuilder ruleBuilder,
+ IMediator mediator,
+ IValidationMessageProvider validationMessageProvider,
+ ValidationMessage? validationMessage = null)
+ where TDto : IHasIdAsPK
+ where TPK : IEquatable
+ {
+ ValidationMessage message = validationMessage ?? validationMessageProvider.ForeignKeyNotFound("{PropertyName}");
+
+ return ruleBuilder.MustAsync(new ForeignKeyValueValidator(mediator).Validate)
+ .WithMessage(message.Message)
+ .WithErrorCode(message.Code);
+ }
+
+ private class ForeignKeyValueValidator where TDto : IHasIdAsPK where TPK : IEquatable
+ {
+ private readonly IMediator mediator;
+
+ public ForeignKeyValueValidator(IMediator mediator)
+ {
+ this.mediator = mediator;
+ }
+
+ public async Task Validate(TCommand command, TPK value, ValidationContext validationContext, CancellationToken cancellationToken)
+ {
+ var query = new DoesItemExistsQuery(value);
+ try
+ {
+ bool itemExists = await mediator.Send(query, cancellationToken);
+ return itemExists;
+ }
+ catch (Exception exc)
+ {
+ validationContext.AddFailure(exc.Message);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/IValidationMessageProvider.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/IValidationMessageProvider.cs
new file mode 100644
index 0000000..52bc356
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/IValidationMessageProvider.cs
@@ -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);
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/NoChildRecordsValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/NoChildRecordsValidator.cs
new file mode 100644
index 0000000..8738160
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/NoChildRecordsValidator.cs
@@ -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 NoChildRecords(this IRuleBuilder ruleBuilder, string columnName, IMediator mediator)
+ {
+ return ruleBuilder.MustAsync(new NoChildRecordsValidator(columnName, mediator).Validate)
+ .WithMessage("Cannot delete entity {PropertyValue} because there are child records in table related to " + typeof(TDto).Name.ToString());
+ }
+
+ private class NoChildRecordsValidator
+ {
+ private readonly string columnName;
+ private readonly IMediator mediator;
+
+ public NoChildRecordsValidator(string columnName, IMediator mediator)
+ {
+ this.columnName = columnName;
+ this.mediator = mediator;
+ }
+
+ public async Task Validate(TPK value, CancellationToken cancellationToken)
+ {
+ var query = new GetCountQuery()
+ {
+ Filters = $"{columnName}=={value}"
+ };
+ int count = await mediator.Send(query, cancellationToken);
+ return count == 0;
+ }
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Person/AddPersonValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/AddPersonValidator.cs
new file mode 100644
index 0000000..cadaace
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/AddPersonValidator.cs
@@ -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>
+{
+ public AddPersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueIndexValidator = new UniqueIndexValidator(
+ 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);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Person/DeletePersonValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/DeletePersonValidator.cs
new file mode 100644
index 0000000..66410b1
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/DeletePersonValidator.cs
@@ -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>
+{
+ public DeletePersonValidator(IMediator mediator)
+ {
+ RuleFor(a => a.Id).NoChildRecords, RegistrationDTO, int>(nameof(RegistrationDTO.PersonId), mediator);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Person/UpdatePersonValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/UpdatePersonValidator.cs
new file mode 100644
index 0000000..96c600c
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Person/UpdatePersonValidator.cs
@@ -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>
+{
+ public UpdatePersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueIndexValidator = new UniqueIndexValidator(
+ 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);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/AddRegistrationValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/AddRegistrationValidator.cs
new file mode 100644
index 0000000..3aa3bc4
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/AddRegistrationValidator.cs
@@ -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>
+{
+ public AddRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueValidator = new UniqueIndexValidator(
+ mediator,
+ (_, _) => validationMessageProvider.UniqueRegistration(),
+ t => t.EventId,
+ t => t.PersonId,
+ t => t.SportId);
+
+ RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
+ RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
+ RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
+ RuleFor(a => a.Dto).CustomAsync(uniqueValidator.Validate);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/DeleteRegistrationValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/DeleteRegistrationValidator.cs
new file mode 100644
index 0000000..3d52aed
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/DeleteRegistrationValidator.cs
@@ -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>
+{
+ public DeleteRegistrationValidator(IMediator mediator)
+ {
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/UpdateRegistrationValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/UpdateRegistrationValidator.cs
new file mode 100644
index 0000000..051ccd7
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Registration/UpdateRegistrationValidator.cs
@@ -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>
+{
+ public UpdateRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueValidator = new UniqueIndexValidator(
+ mediator,
+ (_, _) => validationMessageProvider.UniqueRegistration(),
+ t => t.EventId,
+ t => t.PersonId,
+ t => t.SportId);
+
+ RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
+ RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
+ RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
+ RuleFor(a => a.Dto).CustomAsync(uniqueValidator.ValidateExisting);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/AddSportValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/AddSportValidator.cs
new file mode 100644
index 0000000..da19759
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/AddSportValidator.cs
@@ -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>
+{
+ public AddSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueIndexValidator = new UniqueIndexValidator(
+ 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));
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/DeleteSportValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/DeleteSportValidator.cs
new file mode 100644
index 0000000..dd9b748
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/DeleteSportValidator.cs
@@ -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>
+{
+ public DeleteSportValidator(IMediator mediator)
+ {
+ RuleFor(a => a.Id).NoChildRecords, RegistrationDTO, int>(nameof(RegistrationDTO.SportId), mediator);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/UpdateSportValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/UpdateSportValidator.cs
new file mode 100644
index 0000000..b1e6f3e
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/Sport/UpdateSportValidator.cs
@@ -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>
+{
+ public UpdateSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
+ {
+ var uniqueIndexValidator = new UniqueIndexValidator(
+ 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));
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/UniqueIndexValidator.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/UniqueIndexValidator.cs
new file mode 100644
index 0000000..838fcf4
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/UniqueIndexValidator.cs
@@ -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 where TDto : IHasIdAsPK
+ where TPK : IEquatable
+{
+ private readonly IMediator mediator;
+ private readonly Expression>[] selectors;
+ private readonly Func, IReadOnlyList, ValidationMessage>? errorMessageFactory;
+
+ public UniqueIndexValidator(
+ IMediator mediator,
+ Func, IReadOnlyList, ValidationMessage>? errorMessageFactory = null,
+ params Expression>[] selectors)
+ {
+ this.mediator = mediator;
+ this.errorMessageFactory = errorMessageFactory;
+ this.selectors = selectors;
+ }
+
+ public async Task Validate(string value, ValidationContext> context, CancellationToken cancellationToken)
+ {
+ string columnName = GetColumnName(context);
+
+ var query = new GetCountQuery
+ {
+ 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> context, CancellationToken cancellationToken)
+ {
+ var query = new GetCountQuery();
+
+ List columnNames = [];
+ List values = [];
+ List 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> context, CancellationToken cancellationToken)
+ {
+ string columnName = GetColumnName(context);
+
+ UpdateCommand validatingObject = context.InstanceToValidate;
+ var query = new GetItemsQuery
+ {
+ Filters = $"{columnName}==*{EscapeFilterValue(value)}",
+ Page = 1,
+ PageSize = 2
+ };
+
+ List 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> context, CancellationToken cancellationToken)
+ {
+ var query = new GetItemsQuery();
+
+ List columnNames = [];
+ List values = [];
+ List 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 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 columnNames, IReadOnlyList 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> 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(ValidationContext 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.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
+ };
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationBehaviour.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationBehaviour.cs
new file mode 100644
index 0000000..f315521
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationBehaviour.cs
@@ -0,0 +1,27 @@
+using FluentValidation;
+using MediatR;
+
+namespace Events.WebAPI.Contract.Validation;
+
+public class ValidationBehaviour : IPipelineBehavior where TRequest : IBaseRequest
+{
+ private readonly IEnumerable> validators;
+
+ public ValidationBehaviour(IEnumerable> validators)
+ {
+ this.validators = validators;
+ }
+
+ public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
+ {
+ if (validators.Any())
+ {
+ var context = new ValidationContext(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();
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationErrorCodes.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationErrorCodes.cs
new file mode 100644
index 0000000..db40e5e
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationErrorCodes.cs
@@ -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";
+}
diff --git a/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationMessage.cs b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationMessage.cs
new file mode 100644
index 0000000..fef179b
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Contract/Validation/ValidationMessage.cs
@@ -0,0 +1,3 @@
+namespace Events.WebAPI.Contract.Validation;
+
+public sealed record ValidationMessage(string Code, string Message);
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/EventsCommandsHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/EventsCommandsHandler.cs
new file mode 100644
index 0000000..f5ea221
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/EventsCommandsHandler.cs
@@ -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
+{
+ public EventsCommandsHandler(EventsContext ctx, ILogger logger, IMapper mapper)
+ : base(ctx, logger, mapper)
+ {
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs
new file mode 100644
index 0000000..80694fa
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs
@@ -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 : IRequestHandler, TPK>,
+ IRequestHandler>,
+ IRequestHandler>
+ where TDal: class, IHasIdAsPK
+ where TDto: IHasIdAsPK
+ where TPK : IEquatable
+{
+ 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 Handle(AddCommand request, CancellationToken cancellationToken)
+ {
+ var entity = Mapper.Map(request.Dto);
+ Ctx.Add(entity);
+ await Ctx.SaveChangesAsync(cancellationToken);
+ return entity.Id;
+ }
+
+ public virtual async Task Handle(UpdateCommand request, CancellationToken cancellationToken)
+ {
+ var entity = await Ctx.Set().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 request, CancellationToken cancellationToken)
+ {
+ await Ctx.Set().Where(d => d.Id.Equals(request.Id)).ExecuteDeleteAsync(cancellationToken);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/PeopleCommandsHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/PeopleCommandsHandler.cs
new file mode 100644
index 0000000..73fb46a
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/PeopleCommandsHandler.cs
@@ -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
+{
+ public PeopleCommandsHandler(EventsContext ctx, ILogger logger, IMapper mapper)
+ : base(ctx, logger, mapper)
+ {
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs
new file mode 100644
index 0000000..b3e41ed
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs
@@ -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
+{
+ private readonly IPublishEndpoint publishEndpoint;
+
+ public RegistrationsCommandsHandler(
+ EventsContext ctx,
+ ILogger logger,
+ IMapper mapper,
+ IPublishEndpoint publishEndpoint)
+ : base(ctx, logger, mapper)
+ {
+ this.publishEndpoint = publishEndpoint;
+ }
+
+ public override async Task Handle(AddCommand 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 request, CancellationToken cancellationToken)
+ {
+ var entity = await Ctx.Set().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 request, CancellationToken cancellationToken)
+ {
+ var entity = await Ctx.Set()
+ .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);
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/SportsCommandsHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/SportsCommandsHandler.cs
new file mode 100644
index 0000000..cbf9f74
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/SportsCommandsHandler.cs
@@ -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
+ {
+ public SportsCommandsHandler(EventsContext ctx, ILogger logger, IMapper mapper)
+ : base(ctx, logger, mapper)
+ {
+ }
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Data/Postgres/EventsContext.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Data/Postgres/EventsContext.cs
new file mode 100644
index 0000000..a2b36c9
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Data/Postgres/EventsContext.cs
@@ -0,0 +1,165 @@
+// This file has been auto generated by EF Core Power Tools.
+#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 options)
+ : base(options)
+ {
+ }
+
+ public virtual DbSet Countries { get; set; }
+
+ public virtual DbSet Events { get; set; }
+
+ public virtual DbSet People { get; set; }
+
+ public virtual DbSet Registrations { get; set; }
+
+ public virtual DbSet Sports { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(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(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(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(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(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);
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Events.WebAPI.Handlers.EF.csproj b/Events-WebApi/Events.WebAPI.Handlers.EF/Events.WebAPI.Handlers.EF.csproj
new file mode 100644
index 0000000..559e436
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Events.WebAPI.Handlers.EF.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Mappings/EFMappingProfile.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Mappings/EFMappingProfile.cs
new file mode 100644
index 0000000..fd0baea
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Mappings/EFMappingProfile.cs
@@ -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()
+ .ForMember(o => o.RegistrationsCount, opt => opt.MapFrom(src => src.Registrations.Count));
+ CreateMap()
+ .ForMember(o => o.Id, opt => opt.Ignore());
+
+ CreateMap()
+ .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()
+ .ForMember(o => o.Id, opt => opt.Ignore());
+
+ CreateMap()
+ .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()
+ .ForMember(o => o.Id, opt => opt.Ignore())
+ .ForMember(o => o.RegisteredAt, opt => opt.Ignore());
+
+ CreateMap();
+ CreateMap()
+ .ForMember(o => o.Id, opt => opt.Ignore());
+ }
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Event.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Event.cs
new file mode 100644
index 0000000..ca6f67f
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Event.cs
@@ -0,0 +1,7 @@
+using Events.WebAPI.Contract.DTOs;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Event : IHasIdAsPK
+{
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Person.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Person.cs
new file mode 100644
index 0000000..60b37e4
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Person.cs
@@ -0,0 +1,7 @@
+using Events.WebAPI.Contract.DTOs;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Person : IHasIdAsPK
+{
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Registration.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Registration.cs
new file mode 100644
index 0000000..5b2b496
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Registration.cs
@@ -0,0 +1,7 @@
+using Events.WebAPI.Contract.DTOs;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Registration : IHasIdAsPK
+{
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Sport.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Sport.cs
new file mode 100644
index 0000000..64095bf
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models-Partial/Sport.cs
@@ -0,0 +1,7 @@
+using Events.WebAPI.Contract.DTOs;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Sport : IHasIdAsPK
+{
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Country.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Country.cs
new file mode 100644
index 0000000..816a897
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Country.cs
@@ -0,0 +1,19 @@
+// This file has been auto generated by EF Core Power Tools.
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Country
+{
+ public string Code { get; set; } = null!;
+
+ public string Alpha3 { get; set; } = null!;
+
+ public string Name { get; set; } = null!;
+
+ public string? Translations { get; set; }
+
+ public virtual ICollection People { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Event.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Event.cs
new file mode 100644
index 0000000..8806306
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Event.cs
@@ -0,0 +1,17 @@
+// This file has been auto generated by EF Core Power Tools.
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Event
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = null!;
+
+ public DateOnly EventDate { get; set; }
+
+ public virtual ICollection Registrations { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Person.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Person.cs
new file mode 100644
index 0000000..427dfb6
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Person.cs
@@ -0,0 +1,41 @@
+// This file has been auto generated by EF Core Power Tools.
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Person
+{
+ public int Id { get; set; }
+
+ public string? FirstName { get; set; }
+
+ public string? LastName { get; set; }
+
+ public string FirstNameTranscription { get; set; } = null!;
+
+ public string LastNameTranscription { get; set; } = null!;
+
+ public string? AddressLine { get; set; }
+
+ public string? PostalCode { get; set; }
+
+ public string? City { get; set; }
+
+ public string? AddressCountry { get; set; }
+
+ public string? Email { get; set; }
+
+ public string? ContactPhone { get; set; }
+
+ public DateOnly BirthDate { get; set; }
+
+ public string DocumentNumber { get; set; } = null!;
+
+ public string CountryCode { get; set; } = null!;
+
+ public virtual Country CountryCodeNavigation { get; set; } = null!;
+
+ public virtual ICollection Registrations { get; set; } = new List();
+}
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Registration.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Registration.cs
new file mode 100644
index 0000000..4ed98a0
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Registration.cs
@@ -0,0 +1,25 @@
+// This file has been auto generated by EF Core Power Tools.
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Registration
+{
+ public int Id { get; set; }
+
+ public int PersonId { get; set; }
+
+ public int SportId { get; set; }
+
+ public int EventId { get; set; }
+
+ public DateTime RegisteredAt { get; set; }
+
+ public virtual Event Event { get; set; } = null!;
+
+ public virtual Person Person { get; set; } = null!;
+
+ public virtual Sport Sport { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Sport.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Sport.cs
new file mode 100644
index 0000000..fa3874f
--- /dev/null
+++ b/Events-WebApi/Events.WebAPI.Handlers.EF/Models/Sport.cs
@@ -0,0 +1,15 @@
+// This file has been auto generated by EF Core Power Tools.
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+namespace Events.WebAPI.Handlers.EF.Models;
+
+public partial class Sport
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = null!;
+
+ public virtual ICollection Registrations { get; set; } = new List