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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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(); +} \ No newline at end of file diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/CountriesLookupQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/CountriesLookupQueryHandler.cs new file mode 100644 index 0000000..d8a5699 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/CountriesLookupQueryHandler.cs @@ -0,0 +1,37 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.LookupQueries; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class CountriesLookupQueryHandler : IRequestHandler>> +{ + private readonly EventsContext ctx; + + public CountriesLookupQueryHandler(EventsContext ctx) + { + this.ctx = ctx; + } + + public async Task>> Handle(LookupCountryQuery request, CancellationToken cancellationToken) + { + var query = ctx.Countries.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(request.Text)) + { + string text = request.Text.Trim(); + query = query.Where(c => Microsoft.EntityFrameworkCore.EF.Functions.ILike(c.Name, $"%{text}%")); + } + + return await query + .OrderBy(c => c.Name) + .Select(c => new IdName + { + Id = c.Code, + Name = c.Name + }) + .ToListAsync(cancellationToken); + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/EventsQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/EventsQueryHandler.cs new file mode 100644 index 0000000..bd9a0f7 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/EventsQueryHandler.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using Events.WebAPI.Handlers.EF.Models; +using Events.WebAPI.Handlers.EF.QueryHandlers.Generic; +using Microsoft.Extensions.Logging; +using Sieve.Services; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class EventsQueryHandler : GenericQueryHandler +{ + public EventsQueryHandler(EventsContext ctx, ILogger logger, IMapper mapper, ISieveProcessor sieveProcessor) + : base(ctx, logger, mapper, sieveProcessor) + { + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/Generic/GenericQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/Generic/GenericQueryHandler.cs new file mode 100644 index 0000000..9dcd2ae --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/Generic/GenericQueryHandler.cs @@ -0,0 +1,97 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.Queries.Generic; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Sieve.Models; +using Sieve.Services; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers.Generic +{ + public abstract class GenericQueryHandler : + IRequestHandler, TDto?>, + IRequestHandler, int>, + IRequestHandler, bool>, + IRequestHandler, List> + where TDal : class, IHasIdAsPK + where TPK : IEquatable + { + private readonly DbContext ctx; + protected readonly ILogger logger; + private readonly IMapper mapper; + private readonly ISieveProcessor sieveProcessor; + + public GenericQueryHandler(DbContext ctx, ILogger logger, IMapper mapper, ISieveProcessor sieveProcessor) + { + this.ctx = ctx; + this.logger = logger; + this.mapper = mapper; + this.sieveProcessor = sieveProcessor; + } + + public virtual async Task Handle(GetCountQuery request, CancellationToken cancellationToken) + { + var query = ctx.Set().AsNoTracking(); + IQueryable projectedQuery = mapper.ProjectTo(query); + SieveModel sieveModel = new SieveModel() + { + Filters = request.Filters, + }; + var filteredQuery = sieveProcessor.Apply(sieveModel, projectedQuery, applyFiltering: true, applySorting: false, applyPagination: false); + + int count = await filteredQuery.CountAsync(cancellationToken); + return count; + } + + public virtual async Task Handle(GetSingleItemQuery request, CancellationToken cancellationToken) + { + var query = ctx.Set() + .AsNoTracking() + .Where(t => t.Id.Equals(request.Id)); + + IQueryable projectedQuery = mapper.ProjectTo(query); + var item = await projectedQuery.FirstOrDefaultAsync(cancellationToken); + return item; + } + + public virtual async Task Handle(DoesItemExistsQuery request, CancellationToken cancellationToken) + { + var query = ctx.Set() + .AsNoTracking() + .Where(t => t.Id.Equals(request.Id)); + + bool exists = await query.AnyAsync(cancellationToken); + return exists; + } + + public virtual async Task> Handle(GetItemsQuery request, CancellationToken cancellationToken) + { + var query = ctx.Set().AsNoTracking(); + IQueryable projectedQuery = mapper.ProjectTo(query); + + SieveModel sieveModel = new SieveModel() + { + Filters = request.Filters, + Sorts = BuildSortExpression(request), + PageSize = request.PageSize, + Page = request.Page + }; + var filteredQuery = sieveProcessor.Apply(sieveModel, projectedQuery, applyFiltering: true, applySorting: true, applyPagination: true); + + var data = await filteredQuery.ToListAsync(cancellationToken); + return data; + } + + private static string? BuildSortExpression(GetItemsQuery request) + { + if (!string.IsNullOrWhiteSpace(request.Sort)) + { + return request.Ascending ? request.Sort : "-" + request.Sort; + } + + bool paginationRequested = request.Page.HasValue || request.PageSize.HasValue; + return paginationRequested ? nameof(IHasIdAsPK.Id) : null; + } + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleLookupQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleLookupQueryHandler.cs new file mode 100644 index 0000000..0431524 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleLookupQueryHandler.cs @@ -0,0 +1,48 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.LookupQueries; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class PeopleLookupQueryHandler : IRequestHandler>> +{ + private readonly EventsContext ctx; + + public PeopleLookupQueryHandler(EventsContext ctx) + { + this.ctx = ctx; + } + + public async Task>> Handle(LookupPeopleQuery request, CancellationToken cancellationToken) + { + var query = ctx.People.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(request.Text)) + { + string text = request.Text.Trim(); + query = query.Where(p => + global::Microsoft.EntityFrameworkCore.EF.Functions.ILike( + p.FirstNameTranscription + " " + p.LastNameTranscription, + $"%{text}%")); + } + + if (!string.IsNullOrWhiteSpace(request.CountryCode)) + { + string countryCode = request.CountryCode.Trim(); + query = query.Where(p => p.CountryCode == countryCode); + } + + return await query + .OrderBy(p => p.FirstNameTranscription) + .ThenBy(p => p.LastNameTranscription) + .Select(p => new IdName + { + Id = p.Id, + Name = p.FirstName + " " + p.LastName, + Description = p.FirstNameTranscription + " " + p.LastNameTranscription + }) + .ToListAsync(cancellationToken); + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleQueryHandler.cs new file mode 100644 index 0000000..cc135c4 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/PeopleQueryHandler.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using Events.WebAPI.Handlers.EF.Models; +using Events.WebAPI.Handlers.EF.QueryHandlers.Generic; +using Microsoft.Extensions.Logging; +using Sieve.Services; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class PeopleQueryHandler : GenericQueryHandler +{ + public PeopleQueryHandler(EventsContext ctx, ILogger logger, IMapper mapper, ISieveProcessor sieveProcessor) + : base(ctx, logger, mapper, sieveProcessor) + { + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/RegistrationsQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/RegistrationsQueryHandler.cs new file mode 100644 index 0000000..f3858de --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/RegistrationsQueryHandler.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using Events.WebAPI.Handlers.EF.Models; +using Events.WebAPI.Handlers.EF.QueryHandlers.Generic; +using Microsoft.Extensions.Logging; +using Sieve.Services; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class RegistrationsQueryHandler : GenericQueryHandler +{ + public RegistrationsQueryHandler(EventsContext ctx, ILogger logger, IMapper mapper, ISieveProcessor sieveProcessor) + : base(ctx, logger, mapper, sieveProcessor) + { + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/SportsQueryHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/SportsQueryHandler.cs new file mode 100644 index 0000000..ffc2522 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/QueryHandlers/SportsQueryHandler.cs @@ -0,0 +1,18 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using Events.WebAPI.Handlers.EF.Models; +using Events.WebAPI.Handlers.EF.QueryHandlers.Generic; +using Microsoft.Extensions.Logging; +using Sieve.Services; + +namespace Events.WebAPI.Handlers.EF.QueryHandlers; + +public class SportsQueryHandler : GenericQueryHandler +{ + public SportsQueryHandler(EventsContext ctx, ILogger logger, IMapper mapper, ISieveProcessor sieveProcessor) + : base(ctx, logger, mapper, sieveProcessor) + { + + } +} diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/efpt.postgres.config.json b/Events-WebApi/Events.WebAPI.Handlers.EF/efpt.postgres.config.json new file mode 100644 index 0000000..5440990 --- /dev/null +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/efpt.postgres.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/Postgres", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.WebAPI.Handlers.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "public.country", + "ObjectType": 0 + }, + { + "Name": "public.event", + "ObjectType": 0 + }, + { + "Name": "public.person", + "ObjectType": 0 + }, + { + "Name": "public.registration", + "ObjectType": 0 + }, + { + "Name": "public.sport", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/Events-WebApi/Events.WebAPI/Constants.cs b/Events-WebApi/Events.WebAPI/Constants.cs new file mode 100644 index 0000000..ddc888b --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Constants.cs @@ -0,0 +1,6 @@ +namespace Events.WebAPI; + +public class Constants +{ + public static string ApiVersion => "1.0.0"; +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/EventsController.cs b/Events-WebApi/Events.WebAPI/Controllers/EventsController.cs new file mode 100644 index 0000000..77fbf02 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/EventsController.cs @@ -0,0 +1,8 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Controllers.Generic; + +namespace Events.WebAPI.Controllers; + +public class EventsController : CrudController +{ +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs b/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs new file mode 100644 index 0000000..900c6da --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs @@ -0,0 +1,174 @@ +using Events.WebAPI.Contract.Command; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.Queries.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using MobilityOne.Common.Commands; + +namespace Events.WebAPI.Controllers.Generic; + + +public abstract class CrudController : GetController + where TDto : class, IHasIdAsPK + where TPK : IEquatable +{ + + /// + /// Creates a new item. + /// + /// id does not have to be sent (if sent it would be ignored) + /// + /// A newly created item + /// Returns the newly created item (route to the item, and the item in the body) + /// If the model is null or not valid + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = nameof(Policies.EditData))] + public virtual async Task> Create(TDto model, [FromServices] IMediator mediator) + { + //Note: It never produce ActionResult but we need this because of Swagger description + //We cannot user generic type in attributes (i.e. in ProducesResponseType) + //if successful it returns ActionResult with Value:null and Result:CreatedAtAction + //Thus result.Result.Value is TDto + + var command = new AddCommand(model); + TPK id = await mediator.Send(command); + + var query = new GetSingleItemQuery(id); + + var item = await mediator.Send(query); + var action = CreatedAtAction(nameof(Get), new { id }, item); + + return action; + } + + /// + /// Update the item + /// + /// + /// + /// + /// + /// if the update was successful + /// if there is no item with sent id, or if a user does not have a permission to update the item + /// If the model is not valid + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = nameof(Policies.EditData))] + public virtual async Task Update(TPK id, TDto model, [FromServices] IMediator mediator) + { + if (!model.Id.Equals(id)) //ModelState.IsValid & model != null checked automatically due to [ApiController] + { + return Problem(statusCode: StatusCodes.Status400BadRequest, detail: $"Different ids: {id} vs {model.Id}"); + } + else + { + var query = new GetSingleItemQuery(id); + var item = await mediator.Send(query); + if (item == null) + { + return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}"); + } + + await DoUpdate(model, mediator); + + return NoContent(); + } + } + + private static async Task DoUpdate(TDto model, IMediator mediator) + { + var command = new UpdateCommand(model); + await mediator.Send(command); + } + + /// + /// Partially update the item + /// + /// + /// RFC 6902 formatted json + /// + /// + /// if the update was successful + /// if there is no item with sent id, or if a user does not have a permission to update the item + /// If the patched model is not valid + [HttpPatch("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = nameof(Policies.EditData))] + public virtual async Task UpdatePartially(TPK id, + JsonPatchDocument delta, + [FromServices] IMediator mediator) + { + //Get current DTO based on id (Get will also check for permission to run patch in contrast to direct retrieval)( + var getResult = await base.Get(id, mediator); + if (getResult.Value != null) + { + string problem = string.Empty; + bool ok = true; + TDto dto = getResult.Value; + delta.ApplyTo(dto, patchError => + { + ok = false; + problem = $"{patchError.Operation} causing error: {patchError.ErrorMessage}"; + }); + if (ok) + { + if (!dto.Id.Equals(id)) //ensures that id has not been changed + { + problem = $"Id mismatch after patching {id} <> {dto.Id}"; + return Problem(detail: problem, statusCode: StatusCodes.Status400BadRequest); + } + else + { + await DoUpdate(dto, mediator); + return NoContent(); + } + } + else + { + return Problem(detail: problem, statusCode: StatusCodes.Status400BadRequest); + } + } + else + { + return getResult.Result; + } + } + + + + /// + /// Delete the item base on primary key value (id) + /// + /// Primary key value + /// Query/Command (Request) mediator. (Obtained using Dependency Injection from services) + /// + /// If the item is deleted + /// If the item with id does not exist + /// If the valiation exists, and fails + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = nameof(Policies.EditData))] + public virtual async Task Delete(TPK id, [FromServices] IMediator mediator) + { + var query = new GetSingleItemQuery(id); + var item = await mediator.Send(query); + if (item == null) + { + return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}"); + } + + var command = new DeleteCommand(id); + await mediator.Send(command); + return NoContent(); + } +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs b/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs new file mode 100644 index 0000000..70a1258 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs @@ -0,0 +1,89 @@ +using AutoMapper; +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.Queries.Generic; +using Events.WebAPI.Models; +using Events.WebAPI.Util.Middleware; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Events.WebAPI.Controllers.Generic; + +[Authorize(Policy = nameof(Policies.ReadData))] +[ApiController] +[Route("[controller]")] +[TypeFilter(typeof(BadRequestOnRuleValidationException), Order = 20)] +[TypeFilter(typeof(ProblemDetailsForSqlException), Order = 10)] +[TypeFilter(typeof(ProblemDetailsForException), Order = 1)] //last one +public abstract class GetController : ControllerBase + where TPK : IEquatable +{ + + /// + /// Get number of item satisfying filters + /// + /// Each filter is like key(operator)value, using Sieve syntax + /// + /// + /// + [HttpGet(nameof(Count))] + public virtual async Task Count(string filters, [FromServices] IMediator mediator, [FromServices] IMapper mapper) + { + var countRequest = new GetCountQuery + { + Filters = filters, + }; + int count = await mediator.Send(countRequest); + return count; + } + + /// + /// Returns single item based on primary key value + /// + /// + /// + /// + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public virtual async Task> Get(TPK id, [FromServices] IMediator mediator) + { + var query = new GetSingleItemQuery(id); + var item = await mediator.Send(query); + return item != null ? item : Problem(statusCode: StatusCodes.Status404NotFound, detail: $"No data for id = {id}"); + } + + /// + /// Get all items based on (lazy) load parameters (paging, sorting, and filtering) + /// + /// + /// + /// + /// + [HttpGet] + public virtual async Task> GetAll([FromQuery] LoadParams loadParams, [FromServices] IMediator mediator, [FromServices] IMapper mapper) + { + loadParams ??= new(); + var result = new Items(); + var countRequest = new GetCountQuery + { + Filters = loadParams.Filters + }; + result.Count = await mediator.Send(countRequest); + + if (result.Count > 0) + { + var dataRequest = new GetItemsQuery + { + Filters = loadParams.Filters, + Sort = loadParams.Sort, + Page = loadParams.Page, + PageSize = loadParams.PageSize, + Ascending = loadParams.Ascending + }; + + result.Data = await mediator.Send(dataRequest); + } + return result; + } +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs b/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs new file mode 100644 index 0000000..af1a608 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs @@ -0,0 +1,31 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Contract.LookupQueries; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Events.WebAPI.Controllers; + +[ApiController] +[Route("[controller]/[action]")] +public class LookupController : ControllerBase +{ + [HttpGet] + public async Task>>> Countries(string? text, [FromServices] IMediator mediator) + { + var countries = await mediator.Send(new LookupCountryQuery { Text = text }); + return countries; + } + + [Authorize(Policy = nameof(Policies.ReadData))] + [HttpGet] + public async Task>>> People(string? text, string? countryCode, [FromServices] IMediator mediator) + { + var people = await mediator.Send(new LookupPeopleQuery + { + Text = text, + CountryCode = countryCode + }); + return people; + } +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/PeopleController.cs b/Events-WebApi/Events.WebAPI/Controllers/PeopleController.cs new file mode 100644 index 0000000..aa26f32 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/PeopleController.cs @@ -0,0 +1,8 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Controllers.Generic; + +namespace Events.WebAPI.Controllers; + +public class PeopleController : CrudController +{ +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/RegistrationsController.cs b/Events-WebApi/Events.WebAPI/Controllers/RegistrationsController.cs new file mode 100644 index 0000000..8ef2d64 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/RegistrationsController.cs @@ -0,0 +1,8 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Controllers.Generic; + +namespace Events.WebAPI.Controllers; + +public class RegistrationsController : CrudController +{ +} diff --git a/Events-WebApi/Events.WebAPI/Controllers/SportsController.cs b/Events-WebApi/Events.WebAPI/Controllers/SportsController.cs new file mode 100644 index 0000000..451aaba --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Controllers/SportsController.cs @@ -0,0 +1,8 @@ +using Events.WebAPI.Contract.DTOs; +using Events.WebAPI.Controllers.Generic; + +namespace Events.WebAPI.Controllers; + +public class SportsController : CrudController +{ +} diff --git a/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj b/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj new file mode 100644 index 0000000..4e26d7d --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + + + + true + 1701;1702;1591 + + + + PI + + + + + + + + + + + + + + + + + diff --git a/Events-WebApi/Events.WebAPI/Models/LoadParams.cs b/Events-WebApi/Events.WebAPI/Models/LoadParams.cs new file mode 100644 index 0000000..696d653 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Models/LoadParams.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Events.WebAPI.Models; + +/// +/// Map lazy loading parameters (e.g. from PrimeNG table) +/// +public class LoadParams +{ + /// + /// Page to load + /// + public int Page { get; set; } = 1; + /// + /// Number of elements to return + /// + public int? PageSize { get; set; } + /// + /// Name of a column. Must be same as in corresponding DTO object, case insensitive + /// In case of multiple columns, separated them with comma and without spaces + /// + public string? Sort { get; set; } + /// + /// 1 ascending, -1 descending + /// + public int? SortOrder { get; set; } + + /// + /// Sieve style filter string + /// + public string? Filters { get; set; } + + [BindNever] public bool Ascending => SortOrder == null || SortOrder != -1; +} diff --git a/Events-WebApi/Events.WebAPI/Policies.cs b/Events-WebApi/Events.WebAPI/Policies.cs new file mode 100644 index 0000000..ec88332 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Policies.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Events.WebAPI; + +public class Policies +{ + public static IEnumerable>> All + { + get + { + yield return new KeyValuePair>(nameof(ReadData), ReadData); + yield return new KeyValuePair>(nameof(EditData), EditData); + } + } + + public static Action ReadData + { + get + { + return policy => policy.RequireClaim("scope", "events:read"); + } + } + + public static Action EditData + { + get + { + return policy => policy.RequireClaim("scope", "events:write"); + } + } +} diff --git a/Events-WebApi/Events.WebAPI/Program.Testing.cs b/Events-WebApi/Events.WebAPI/Program.Testing.cs new file mode 100644 index 0000000..9fd2fef --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Program.Testing.cs @@ -0,0 +1,5 @@ +namespace Events.WebAPI; + +public partial class Program +{ +} diff --git a/Events-WebApi/Events.WebAPI/Program.cs b/Events-WebApi/Events.WebAPI/Program.cs new file mode 100644 index 0000000..88820c1 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Program.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using AutoMapper; +using Events.WebAPI; +using Events.WebAPI.Contract.Validation.Sport; +using Events.WebAPI.Contract.Validation; +using Events.WebAPI.Handlers.EF.Mappings; +using Events.WebAPI.Handlers.EF.QueryHandlers; +using Events.WebAPI.Handlers.EF.Data.Postgres; +using Events.WebAPI.Util.Extensions; +using Events.WebAPI.Util.Startup; +using Events.WebAPI.Util.Swagger; +using Events.WebAPI.Util.Validation; +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi; +using Sieve.Models; +using Sieve.Services; + +var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddControllers(options => options.AddJsonPatchSupport()) + .AddJsonOptions(configure => configure.JsonSerializerOptions.PropertyNamingPolicy = null); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB"))); + +builder.Services.Configure(builder.Configuration.GetSection("Sieve")); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); +builder.Services.AddValidatorsFromAssemblyContaining(typeof(AddSportValidator)); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssembly(typeof(SportsQueryHandler).Assembly); +}); + +builder.Services.SetupMassTransit(builder.Configuration); +builder.Services.SetupAuthenticationAndAuthorization(builder.Configuration); + +#region AutoMapper settings +Action mapperConfigAction = (serviceProvider, cfg) => +{ + cfg.ConstructServicesUsing(serviceProvider.GetService); +}; +builder.Services.AddAutoMapper(mapperConfigAction, typeof(EFMappingProfile)); //assemblies containing mapping profiles +#endregion + +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc(Constants.ApiVersion, new OpenApiInfo { Title = "Events API", Version = Constants.ApiVersion }); + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + c.AddBearerTokenScheme(); +}); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.RoutePrefix = "docs"; + c.DocumentTitle = "Events WebApi"; + c.SwaggerEndpoint($"../swagger/{Constants.ApiVersion}/swagger.json", "Events WebAPI"); +}); + +app.UseRouting(); + +app.UseCors(builder => +{ + builder + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + .WithExposedHeaders("Token-Expired", "Content-Disposition"); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.Run(); diff --git a/Events-WebApi/Events.WebAPI/Properties/launchSettings.json b/Events-WebApi/Events.WebAPI/Properties/launchSettings.json new file mode 100644 index 0000000..83cc597 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/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:7295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Extensions/AddJsonPatchSupportExtension.cs b/Events-WebApi/Events.WebAPI/Util/Extensions/AddJsonPatchSupportExtension.cs new file mode 100644 index 0000000..2bbb6ad --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Extensions/AddJsonPatchSupportExtension.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Events.WebAPI.Util.Extensions; + +public static class AddJsonPatchSupportExtension +{ + public static void AddJsonPatchSupport(this MvcOptions options) + { + options.InputFormatters.Insert(0, GetJsonPatchInputFormatter()); + } + + // Add Newtonsoft only to the JSON Patch formatter so the rest of the API keeps System.Text.Json. + private static NewtonsoftJsonPatchInputFormatter GetJsonPatchInputFormatter() + { + var builder = new ServiceCollection() + .AddLogging() + .AddControllers() + .AddNewtonsoftJson() + .Services + .BuildServiceProvider(); + + return builder + .GetRequiredService>() + .Value + .InputFormatters + .OfType() + .First(); + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Extensions/DictionaryExtensions.cs b/Events-WebApi/Events.WebAPI/Util/Extensions/DictionaryExtensions.cs new file mode 100644 index 0000000..3f9da3e --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Extensions/DictionaryExtensions.cs @@ -0,0 +1,20 @@ +namespace Events.WebAPI.Util.Extensions; + +public static class DictionaryExtensions +{ + public static TValue GetOrCreate(this Dictionary dict, TKey key) where TValue : new() where TKey : notnull + { + if (!dict.ContainsKey(key)) + { + var item = new TValue(); + dict[key] = item; + return item; + } + else + { + return dict[key]; + } + } + + +} diff --git a/Events-WebApi/Events.WebAPI/Util/Extensions/ExceptionExtensions.cs b/Events-WebApi/Events.WebAPI/Util/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..e62d3dc --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Extensions/ExceptionExtensions.cs @@ -0,0 +1,28 @@ +using System.Text; + +namespace Events.WebAPI.Util.Extensions +{ + /// + /// Class with useful extensions for exceptions handling + /// + public static class ExceptionExtensions + { + /// + /// return complete hierarchy of an exception. It checks whether the exception has inner exception, + /// and if it has, then it appends inner exception message. + /// Then it looks for inner exception of the inner exceptions, and so on. + /// + /// Exception which message hiearchy should be obtained + /// String containing all exception hierarchy messages + public static string CompleteExceptionMessage(this Exception? exc) + { + StringBuilder sb = new(); + while (exc != null) + { + sb.AppendLine(exc.Message); + exc = exc.InnerException; + } + return sb.ToString(); + } + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Middleware/BadRequestOnRuleValidationException.cs b/Events-WebApi/Events.WebAPI/Util/Middleware/BadRequestOnRuleValidationException.cs new file mode 100644 index 0000000..776f465 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Middleware/BadRequestOnRuleValidationException.cs @@ -0,0 +1,68 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Events.WebAPI.Util.Extensions; +using Events.WebAPI.Contract.Command; + +namespace Events.WebAPI.Util.Middleware; + +public class BadRequestOnRuleValidationException : ExceptionFilterAttribute +{ + private readonly ILogger logger; + + public BadRequestOnRuleValidationException(ILogger logger) + { + this.logger = logger; + } + + public override void OnException(ExceptionContext context) + { + if (context.Exception is ValidationException) + { + + string exceptionMessage = context.Exception.CompleteExceptionMessage(); + logger.LogDebug("Validation error: {0}", exceptionMessage); + + ValidationException exc = (ValidationException)context.Exception; + Dictionary> validationErrors = new Dictionary>(); + Dictionary> validationErrorCodes = new Dictionary>(); + + foreach(var failure in exc.Errors) + { + //remove prefix Dto. (part of Update and AddCommand) + string propertyName = failure.PropertyName.Replace(nameof(AddCommand.Dto) + ".", ""); + if (propertyName == nameof(AddCommand.Dto)) + { + propertyName = string.Empty; + } + + validationErrors.GetOrCreate(propertyName).Add(failure.ErrorMessage); + + if (!string.IsNullOrWhiteSpace(failure.ErrorCode)) + { + validationErrorCodes.GetOrCreate(propertyName).Add(failure.ErrorCode); + } + } + + var problemDetails = new ValidationProblemDetails(validationErrors.ToDictionary(d => d.Key, d => d.Value.ToArray())) + { + Detail = context.Exception.Message, + Title = "Validation exception", + Instance = context.HttpContext.TraceIdentifier + }; + if (validationErrorCodes.Count > 0) + { + problemDetails.Extensions["errorCodes"] = validationErrorCodes.ToDictionary(d => d.Key, d => d.Value.ToArray()); + } + context.Result = new ObjectResult(problemDetails) + { + ContentTypes = { "application/problem+json" }, + StatusCode = StatusCodes.Status400BadRequest + }; + + context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + context.ExceptionHandled = true; + + } + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForException.cs b/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForException.cs new file mode 100644 index 0000000..e3640a6 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForException.cs @@ -0,0 +1,35 @@ +using Events.WebAPI.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Events.WebAPI.Util.Middleware; + +public class ProblemDetailsForException : ExceptionFilterAttribute +{ + private readonly ILogger logger; + + public ProblemDetailsForException(ILogger logger) + { + this.logger = logger; + } + + public override void OnException(ExceptionContext context) + { + string exceptionMessage = context.Exception.CompleteExceptionMessage(); + logger.LogError("Error 500: {0}", exceptionMessage); //TO DO: Log data from context.ActionDescriptor? + logger.LogError(context.Exception.StackTrace); + context.ExceptionHandled = true; + var problemDetails = new ProblemDetails + { + Type = "https://httpstatuses.io/500", + Detail = exceptionMessage, + Title = "Internal server error", + Instance = context.HttpContext.TraceIdentifier + }; + context.Result = new ObjectResult(problemDetails) + { + ContentTypes = { "application/problem+json" }, + StatusCode = StatusCodes.Status500InternalServerError + }; + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForSqlException.cs b/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForSqlException.cs new file mode 100644 index 0000000..00b90ed --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Middleware/ProblemDetailsForSqlException.cs @@ -0,0 +1,73 @@ +using Events.WebAPI.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Events.WebAPI.Util.Middleware; + +public class ProblemDetailsForSqlException : ExceptionFilterAttribute +{ + private readonly ILogger logger; + + public ProblemDetailsForSqlException(ILogger logger) + { + this.logger = logger; + } + + public override void OnException(ExceptionContext context) + { + Exception? exception = context.Exception; + PostgresException? postgresException = null; + + while (exception is not null) + { + if (exception is PostgresException currentPostgresException) + { + postgresException = currentPostgresException; + break; + } + + if (exception is DbUpdateException dbUpdateException && dbUpdateException.InnerException is not null) + { + exception = dbUpdateException.InnerException; + continue; + } + + exception = exception.InnerException; + } + + if (postgresException is null) + { + base.OnException(context); + return; + } + + ProblemDetails problemDetails = postgresException.SqlState switch + { + PostgresErrorCodes.UniqueViolation => new ProblemDetails + { + Title = "Duplicate data", + Detail = "A record with the same data already exists." + }, + PostgresErrorCodes.ForeignKeyViolation => new ProblemDetails + { + Title = "Related data", + Detail = "The operation is not allowed because related data exists." + }, + _ => new ProblemDetails + { + Title = "Database error", + Detail = $"An error occurred while saving data to the database. {postgresException.MessageText}" + } + }; + + logger.LogDebug("Database exception: {message}", context.Exception.CompleteExceptionMessage()); + context.ExceptionHandled = true; + context.Result = new ObjectResult(problemDetails) + { + ContentTypes = { "application/problem+json" }, + StatusCode = StatusCodes.Status500InternalServerError + }; + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Settings/RabbitMqSettings.cs b/Events-WebApi/Events.WebAPI/Util/Settings/RabbitMqSettings.cs new file mode 100644 index 0000000..537415e --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Settings/RabbitMqSettings.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Events.WebAPI.Util.Settings; + +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.WebAPI/Util/Startup/AuthSetupExtensions.cs b/Events-WebApi/Events.WebAPI/Util/Startup/AuthSetupExtensions.cs new file mode 100644 index 0000000..aab681a --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Startup/AuthSetupExtensions.cs @@ -0,0 +1,49 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace Events.WebAPI.Util.Startup; + +public static class AuthSetupExtensions +{ + public static void SetupAuthenticationAndAuthorization(this IServiceCollection services, IConfiguration configuration) + { + Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); + + services.AddScoped(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opt => + { + opt.Authority = configuration["Auth:Authority"]; + opt.Audience = configuration["Auth:Audience"]; + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = true, + ValidateIssuerSigningKey = true, + NameClaimType = ClaimTypes.NameIdentifier + }; + opt.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) + { + context.Response.Headers.Append("Token-Expired", "true"); + } + + return Task.CompletedTask; + } + }; + }); + + services.AddAuthorization(options => + { + foreach (var policy in Policies.All) + { + options.AddPolicy(policy.Key, policy.Value); + } + }); + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Startup/MassTransitSetupExtensions.cs b/Events-WebApi/Events.WebAPI/Util/Startup/MassTransitSetupExtensions.cs new file mode 100644 index 0000000..973187b --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Startup/MassTransitSetupExtensions.cs @@ -0,0 +1,36 @@ +using Events.WebAPI.Util.Settings; +using MassTransit; +using Microsoft.Extensions.Options; + +namespace Events.WebAPI.Util.Startup; + +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.UsingRabbitMq((context, cfg) => + { + var settings = context.GetRequiredService>().Value; + + cfg.Host(new Uri(settings.Host), h => + { + h.Username(settings.Username); + h.Password(settings.Password); + }); + + }); + }); + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs b/Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs new file mode 100644 index 0000000..f03b310 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace Events.WebAPI.Util.Startup; + +public sealed class ScopeClaimsTransformation : IClaimsTransformation +{ + public Task TransformAsync(ClaimsPrincipal principal) + { + if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) + { + return Task.FromResult(principal); + } + + Claim[] combinedScopeClaims = identity + .FindAll("scope") + .Where(claim => claim.Value.Contains(' ')) + .ToArray(); + + if (combinedScopeClaims.Length == 0) + { + return Task.FromResult(principal); + } + + var additionalIdentity = new ClaimsIdentity(); + + foreach (Claim combinedClaim in combinedScopeClaims) + { + foreach (string scope in combinedClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (identity.HasClaim("scope", scope) || additionalIdentity.HasClaim("scope", scope)) + { + continue; + } + + additionalIdentity.AddClaim(new Claim("scope", scope, combinedClaim.ValueType, combinedClaim.Issuer)); + } + } + + if (additionalIdentity.Claims.Any()) + { + principal.AddIdentity(additionalIdentity); + } + + return Task.FromResult(principal); + } +} diff --git a/Events-WebApi/Events.WebAPI/Util/Swagger/AddBearerTokenScheme.cs b/Events-WebApi/Events.WebAPI/Util/Swagger/AddBearerTokenScheme.cs new file mode 100644 index 0000000..98b98d5 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Swagger/AddBearerTokenScheme.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + + +namespace Events.WebAPI.Util.Swagger; + +public static class AddBearerTokenSchemeExtension +{ + public static void AddBearerTokenScheme(this SwaggerGenOptions opt) + { + var jwtSecurityScheme = new OpenApiSecurityScheme + { + Description = "Paste token here", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = JwtBearerDefaults.AuthenticationScheme, + BearerFormat = "JWT", + }; + + //Dodaj Authorize button u Swagger UI + opt.AddSecurityDefinition(jwtSecurityScheme.Scheme, jwtSecurityScheme); + + //opt.AddSecurityRequirement(document => new() { [new OpenApiSecuritySchemeReference(jwtSecurityScheme.Scheme, document)] = [] }); + + // nemoj ga primijeniti na sve operacije (redak iznad), nego samo na one koje imaju Authorize atribut + opt.OperationFilter(); + } +} \ No newline at end of file diff --git a/Events-WebApi/Events.WebAPI/Util/Swagger/AuthorizeOperationFilter.cs b/Events-WebApi/Events.WebAPI/Util/Swagger/AuthorizeOperationFilter.cs new file mode 100644 index 0000000..9958589 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Swagger/AuthorizeOperationFilter.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Events.WebAPI.Util.Swagger; + +public class AuthorizeOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var hasAuthorize = context.MethodInfo.DeclaringType? + .GetCustomAttributes(true) + .OfType().Any() == true + || + context.MethodInfo.GetCustomAttributes(true) + .OfType().Any(); + + if (!hasAuthorize) + return; + + operation.Security ??= new List(); + + operation.Security.Add(new OpenApiSecurityRequirement + { + [ + new OpenApiSecuritySchemeReference( + JwtBearerDefaults.AuthenticationScheme, + context.Document + ) + ] = new List() + }); + } +} \ No newline at end of file diff --git a/Events-WebApi/Events.WebAPI/Util/Validation/ValidationMessageProvider.cs b/Events-WebApi/Events.WebAPI/Util/Validation/ValidationMessageProvider.cs new file mode 100644 index 0000000..2e6f009 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/Util/Validation/ValidationMessageProvider.cs @@ -0,0 +1,30 @@ +using Events.WebAPI.Contract.Validation; + +namespace Events.WebAPI.Util.Validation; + +public class ValidationMessageProvider : IValidationMessageProvider +{ + public ValidationMessage UniqueSportName(string sportName) + => new(ValidationErrorCodes.SportNameNotUnique, $"A sport named '{sportName}' already exists."); + + public ValidationMessage UniquePersonDocumentAndCountry() + => new(ValidationErrorCodes.PersonDocumentCountryNotUnique, "A person with the same document number already exists for the selected country."); + + public ValidationMessage PersonEmailOrContactPhoneRequired() + => new(ValidationErrorCodes.PersonEmailOrContactPhoneRequired, "Either e-mail address or contact phone is required."); + + public ValidationMessage UniqueRegistration() + => new(ValidationErrorCodes.RegistrationNotUnique, "The person is already registered for the selected sport at this event."); + + public ValidationMessage EventNotFound() + => new(ValidationErrorCodes.EventNotFound, "The selected event does not exist."); + + public ValidationMessage PersonNotFound() + => new(ValidationErrorCodes.PersonNotFound, "The selected person does not exist."); + + public ValidationMessage SportNotFound() + => new(ValidationErrorCodes.SportNotFound, "The selected sport does not exist."); + + public ValidationMessage ForeignKeyNotFound(string propertyName) + => new(ValidationErrorCodes.ForeignKeyNotFound, $"The selected value for {propertyName} does not exist."); +} diff --git a/Events-WebApi/Events.WebAPI/appsettings.Development.json b/Events-WebApi/Events.WebAPI/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Events-WebApi/Events.WebAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Events-WebApi/Events.WebAPI/appsettings.json b/Events-WebApi/Events.WebAPI/appsettings.json new file mode 100644 index 0000000..b01deb2 --- /dev/null +++ b/Events-WebApi/Events.WebAPI/appsettings.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*", + "Sieve": { + "CaseSensitive": false, + "DefaultPageSize": 50, + "MaxPageSize": 200, + "ThrowExceptions": true, + "IgnoreNullsOnNotEqual": true, + "DisableNullableTypeExpressionForSorting": false + }, + "RabbitMq": { + "Host": "rabbitmq://localhost", + "Username": "guest", + "Password": "guest" + }, + "ConnectionStrings": { + "EventDB": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" + }, + "Auth": { + "Authority": "https://fer-web2.eu.auth0.com/", + "Audience": "https://erasmus-sta-2026/events-api" + } +} diff --git a/Events-WebApi/README.md b/Events-WebApi/README.md new file mode 100644 index 0000000..2f8381b --- /dev/null +++ b/Events-WebApi/README.md @@ -0,0 +1,121 @@ +## Solution Overview + + + +Swagger UI is exposed at: + +```text +https://localhost:7290/docs +``` + +The exact port may vary depending on your local launch profile. + +## Prerequisites + +- .NET SDK 10.0 +- Docker Desktop +- PostgreSQL, usually via [docker-definitions](docker-definitions/README.md) +- RabbitMQ if you want to run the full API with its real MassTransit transport +- Node.js 20+ for the client app +- An Auth0 tenant if you want to run real bearer-token and browser-login flows outside the test suite + +## Configuration + +`Events.WebAPI` reads settings from: + +- [Events.WebAPI/appsettings.json](Topic2/Events.WebAPI/appsettings.json) +- [Events.WebAPI/appsettings.Development.json](Topic2/Events.WebAPI/appsettings.Development.json) +- the shared .NET user secrets store with id `Erasmus-STA-2026` + +Important configuration sections: + +- `ConnectionStrings:EventDB` +- `RabbitMq:Host` +- `RabbitMq:Username` +- `RabbitMq:Password` +- `Auth:Authority` +- `Auth:Audience` +- `Paths:Certificates` + +The current Auth configuration in [Events.WebAPI/appsettings.json](Topic2/Events.WebAPI/appsettings.json) is: + +- `Auth:Authority=https://fer-web2.eu.auth0.com/` +- `Auth:Audience=https://erasmus-sta-2026/events-api` + +Set the PostgreSQL connection string: + +```powershell +dotnet user-secrets set "ConnectionStrings:EventDB" "Host=localhost;Port=5432;Database=events;Username=sport;Password=your-password;Persist Security Info=True;" --project Topic2\Events.WebAPI\Events.WebAPI.csproj +``` + +You can also override RabbitMQ and Auth settings with user secrets if you do not want to keep local values in `appsettings.json`. + +For the SPA client, copy `Topic2/Events.ClientApp/.env.example` to `.env.local`. The example file already contains the current Auth0 values used by this repository: + +- `VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com` +- `VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3` +- `VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api` + +`Paths:Certificates` points to the directory where generated certificates and Excel files are stored. By default it is: + +```text +./Certificates +``` + +## Running Required Infrastructure + +Start PostgreSQL using the repository Docker definitions: + +```powershell +docker compose -f docker-definitions\postgres-eventsdb\docker-compose.yml up -d +``` + +Start RabbitMQ if you want the API to use its real MassTransit transport: + +```powershell +docker run -d --name rabbitmq-erasmus-sta -p 5672:5672 -p 15672:15672 rabbitmq:4-management +``` + +The RabbitMQ management UI is usually available at: + +```text +http://localhost:15672 +``` + +## Running The Web API + +```powershell +dotnet restore Topic2\Topic2.sln +dotnet build Topic2\Topic2.sln +dotnet run --project Topic2\Events.WebAPI\Events.WebAPI.csproj +``` + +Once the API is running: + +- open Swagger at `/docs` +- test anonymous lookup endpoints +- test secured endpoints with a valid bearer token if your Auth0 configuration is set + +## Running The Client App + +See [Events.ClientApp/README.md](Topic2/Events.ClientApp/README.md) for full details. + +Typical local flow: + +```powershell +cd Topic2\Events.ClientApp +npm install +npm run dev +``` + +The client expects: + +- `VITE_API_BASE_URL` pointing to the running API +- Auth0 SPA settings if login is enabled + +## Troubleshooting + +- If the API fails at startup, verify `ConnectionStrings:EventDB`, RabbitMQ connectivity, and `Paths:Certificates` +- If Swagger opens but secured requests fail, verify `Auth:Authority`, `Auth:Audience`, and the token scopes +- If the client loads but cannot authenticate, verify the values in `.env.local` +- If generated certificates or Excel exports are missing, verify that the output directory exists and is writable diff --git a/Events.GraphQL/Events.EF/Data/MSSQL/EventsContext.cs b/Events.GraphQL/Events.EF/Data/MSSQL/EventsContext.cs new file mode 100644 index 0000000..df5e7a7 --- /dev/null +++ b/Events.GraphQL/Events.EF/Data/MSSQL/EventsContext.cs @@ -0,0 +1,143 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.EF.Data.MSSQL; + +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); + + entity.ToTable("Country"); + + entity.HasIndex(e => e.Name, "UQ_Country_Name").IsUnique(); + + entity.Property(e => e.Code) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.Alpha3) + .HasMaxLength(3) + .IsUnicode(false) + .IsFixedLength(); + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Event"); + + entity.Property(e => e.Name) + .HasMaxLength(150) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Person"); + + entity.HasIndex(e => new { e.DocumentNumber, e.CountryCode }, "UQ_Person_DocumentNumber_CountryCode").IsUnique(); + + entity.Property(e => e.AddressCountry) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.AddressLine) + .HasMaxLength(200) + .IsUnicode(false); + entity.Property(e => e.City) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.ContactPhone) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.CountryCode) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.DocumentNumber) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.Email) + .HasMaxLength(255) + .IsUnicode(false); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.FirstNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.PostalCode) + .HasMaxLength(20) + .IsUnicode(false); + + entity.HasOne(d => d.CountryCodeNavigation).WithMany(p => p.People) + .HasForeignKey(d => d.CountryCode) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Person_Country"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Registration"); + + entity.HasIndex(e => new { e.PersonId, e.SportId, e.EventId }, "UQ_Registration_PersonId_SportId_EventId").IsUnique(); + + entity.Property(e => e.RegisteredAt).HasDefaultValueSql("(sysutcdatetime())", "DF_Registration_RegisteredAt"); + + entity.HasOne(d => d.Event).WithMany(p => p.Registrations) + .HasForeignKey(d => d.EventId) + .HasConstraintName("FK_Registration_Event"); + + entity.HasOne(d => d.Person).WithMany(p => p.Registrations) + .HasForeignKey(d => d.PersonId) + .HasConstraintName("FK_Registration_Person"); + + entity.HasOne(d => d.Sport).WithMany(p => p.Registrations) + .HasForeignKey(d => d.SportId) + .HasConstraintName("FK_Registration_Sport"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Sport"); + + entity.HasIndex(e => e.Name, "UQ_Sport_Name").IsUnique(); + + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/Events.GraphQL/Events.EF/Data/Postgres/EventsContext.cs b/Events.GraphQL/Events.EF/Data/Postgres/EventsContext.cs new file mode 100644 index 0000000..1e66779 --- /dev/null +++ b/Events.GraphQL/Events.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.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.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.GraphQL/Events.EF/Events.EF.csproj b/Events.GraphQL/Events.EF/Events.EF.csproj new file mode 100644 index 0000000..87692b8 --- /dev/null +++ b/Events.GraphQL/Events.EF/Events.EF.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/Events.GraphQL/Events.EF/Models/Country.cs b/Events.GraphQL/Events.EF/Models/Country.cs new file mode 100644 index 0000000..3203a07 --- /dev/null +++ b/Events.GraphQL/Events.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.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.GraphQL/Events.EF/Models/Event.cs b/Events.GraphQL/Events.EF/Models/Event.cs new file mode 100644 index 0000000..13b99f6 --- /dev/null +++ b/Events.GraphQL/Events.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.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.GraphQL/Events.EF/Models/Person.cs b/Events.GraphQL/Events.EF/Models/Person.cs new file mode 100644 index 0000000..c6df0ab --- /dev/null +++ b/Events.GraphQL/Events.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.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.GraphQL/Events.EF/Models/Registration.cs b/Events.GraphQL/Events.EF/Models/Registration.cs new file mode 100644 index 0000000..460d2b1 --- /dev/null +++ b/Events.GraphQL/Events.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.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.GraphQL/Events.EF/Models/Sport.cs b/Events.GraphQL/Events.EF/Models/Sport.cs new file mode 100644 index 0000000..8b126d6 --- /dev/null +++ b/Events.GraphQL/Events.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.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(); +} \ No newline at end of file diff --git a/Events.GraphQL/Events.EF/efpt.mssql.config.json b/Events.GraphQL/Events.EF/efpt.mssql.config.json new file mode 100644 index 0000000..6df4a7a --- /dev/null +++ b/Events.GraphQL/Events.EF/efpt.mssql.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/MSSQL", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "[dbo].[Country]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Event]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Person]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Registration]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Sport]", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/Events.GraphQL/Events.EF/efpt.postgres.config.json b/Events.GraphQL/Events.EF/efpt.postgres.config.json new file mode 100644 index 0000000..38b602d --- /dev/null +++ b/Events.GraphQL/Events.EF/efpt.postgres.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/Postgres", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "public.country", + "ObjectType": 0 + }, + { + "Name": "public.event", + "ObjectType": 0 + }, + { + "Name": "public.person", + "ObjectType": 0 + }, + { + "Name": "public.registration", + "ObjectType": 0 + }, + { + "Name": "public.sport", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/Events.GraphQL/Events.GraphQL.slnx b/Events.GraphQL/Events.GraphQL.slnx new file mode 100644 index 0000000..70b45df --- /dev/null +++ b/Events.GraphQL/Events.GraphQL.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Events.GraphQL/Events.GraphQLServer/Events.GraphQLServer.csproj b/Events.GraphQL/Events.GraphQLServer/Events.GraphQLServer.csproj new file mode 100644 index 0000000..1d940fc --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/Events.GraphQLServer.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + PI + + + + + + + + + + + + + + + diff --git a/Events.GraphQL/Events.GraphQLServer/Program.cs b/Events.GraphQL/Events.GraphQLServer/Program.cs new file mode 100644 index 0000000..2063e1d --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/Program.cs @@ -0,0 +1,53 @@ +using Events.EF.Data.Postgres; +using GraphQL.Server.Ui.Voyager; +using GraphQLServer.SetupGraphQL; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +#region Configure services + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres"))); + +builder.Services.AddGraphQLServer() + .ModifyRequestOptions(opt => opt.IncludeExceptionDetails = builder.Environment.IsDevelopment()) + .ModifyPagingOptions(options => + { + options.DefaultPageSize = 20; + options.MaxPageSize = 1000; + options.IncludeTotalCount = true; + }) + .AddProjections() + .AddFiltering() + .AddSorting() + .AddQueryType() + .AddMutationType(); +#endregion + +var app = builder.Build(); + +#region Configure middleware pipeline. + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseStaticFiles(); + +app.UseRouting(); + +app.MapGraphQL(); + +app.UseGraphQLVoyager("/voyager", new VoyagerOptions() { GraphQLEndPoint = "graphql" }); +app.UseGraphQLGraphiQL( + "/", + new GraphQL.Server.Ui.GraphiQL.GraphiQLOptions + { + GraphQLEndPoint = "/graphql", + SubscriptionsEndPoint = "/graphql", + }); +#endregion + +app.Run(); diff --git a/Events.GraphQL/Events.GraphQLServer/Properties/launchSettings.json b/Events.GraphQL/Events.GraphQLServer/Properties/launchSettings.json new file mode 100644 index 0000000..f3634ac --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/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:50440", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/EventInput.cs b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/EventInput.cs new file mode 100644 index 0000000..d52a81d --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/EventInput.cs @@ -0,0 +1,5 @@ +namespace GraphQLServer.SetupGraphQL; + +public record EventInput( + string Name, + DateOnly EventDate); diff --git a/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.Event.cs b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.Event.cs new file mode 100644 index 0000000..b15d608 --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.Event.cs @@ -0,0 +1,50 @@ +using Events.EF.Data.MSSQL; +using Events.EF.Models; + +namespace GraphQLServer.SetupGraphQL; + +public partial class Mutations +{ + public async Task AddEvent([Service] EventsContext ctx, EventInput input) + { + var item = new Event + { + Name = input.Name, + EventDate = input.EventDate + }; + + ctx.Events.Add(item); + await ctx.SaveChangesAsync(); + + return item; + } + + public async Task UpdateEvent([Service] EventsContext ctx, int id, EventInput input) + { + var item = await ctx.Events.FindAsync(id); + if (item is null) + { + return null; + } + + item.Name = input.Name; + item.EventDate = input.EventDate; + await ctx.SaveChangesAsync(); + + return item; + } + + public async Task DeleteEvent([Service] EventsContext ctx, int id) + { + var item = await ctx.Events.FindAsync(id); + if (item is null) + { + return false; + } + + ctx.Events.Remove(item); + await ctx.SaveChangesAsync(); + + return true; + } +} diff --git a/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.cs b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.cs new file mode 100644 index 0000000..d65f49b --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Mutations.cs @@ -0,0 +1,5 @@ +namespace GraphQLServer.SetupGraphQL; + +public partial class Mutations +{ +} diff --git a/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/PeoplePage.cs b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/PeoplePage.cs new file mode 100644 index 0000000..64ffad1 --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/PeoplePage.cs @@ -0,0 +1,14 @@ +using Events.EF.Models; + +namespace GraphQLServer.SetupGraphQL; + +public class PeoplePage +{ + public required int TotalCount { get; init; } + + public required int PageNumber { get; init; } + + public required int PageSize { get; init; } + + public required IReadOnlyList Items { get; init; } +} diff --git a/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Queries.cs b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Queries.cs new file mode 100644 index 0000000..4f7d5f0 --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/SetupGraphQL/Queries.cs @@ -0,0 +1,74 @@ +using Events.EF.Data.MSSQL; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace GraphQLServer.SetupGraphQL; + +public class Queries +{ + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetCountries([Service] EventsContext ctx) => ctx.Countries.AsNoTracking(); + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetSports([Service] EventsContext ctx) => ctx.Sports.AsNoTracking(); + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetPeople([Service] EventsContext ctx) => ctx.People.AsNoTracking(); + + public async Task GetPeoplePage( + [Service] EventsContext ctx, + int pageNumber = 1, + int pageSize = 10) + { + pageNumber = Math.Max(1, pageNumber); + pageSize = Math.Clamp(pageSize, 1, 100); + + var baseQuery = ctx.People + .AsNoTracking() + .OrderBy(p => p.Id); + + var totalCount = await baseQuery.CountAsync(); + + var items = await baseQuery + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PeoplePage + { + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize, + Items = items + }; + } + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetEvents([Service] EventsContext ctx) => ctx.Events.AsNoTracking(); + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetEventsForDate([Service] EventsContext ctx, DateOnly date) => + ctx.Events.AsNoTracking() + .Where(e => e.EventDate == date); + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetRegistrations([Service] EventsContext ctx) => ctx.Registrations.AsNoTracking(); +} diff --git a/Events.GraphQL/Events.GraphQLServer/appsettings.Development.json b/Events.GraphQL/Events.GraphQLServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Events.GraphQL/Events.GraphQLServer/appsettings.json b/Events.GraphQL/Events.GraphQLServer/appsettings.json new file mode 100644 index 0000000..d3d136c --- /dev/null +++ b/Events.GraphQL/Events.GraphQLServer/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "EventsMssql": "Data Source=.,1433;Initial Catalog=Events;User Id=sport;Password=go and look in the secrets file;TrustServerCertificate=True" + }, + "AllowedHosts": "*" +} diff --git a/Reflection/LottoImplementation/Lotto.cs b/Reflection/LottoImplementation/Lotto.cs new file mode 100644 index 0000000..7b779a1 --- /dev/null +++ b/Reflection/LottoImplementation/Lotto.cs @@ -0,0 +1,37 @@ +using LottoInterfaces; +using System; +using System.Collections.Generic; + +namespace LottoImplementation; + +public class Lotto : ILotto +{ + private int ballsToBeDrawn, numberOfBalls; + public string? SomeProperty { get; set; } + public Lotto(int ballsToBeDrawn, int numberOfBalls) + { + this.ballsToBeDrawn = ballsToBeDrawn; + this.numberOfBalls = numberOfBalls; + } + + public List DrawNumbers(bool sort) + { + List list = new List(); + Random r = new Random(DateTime.Now.Millisecond); + int counter = ballsToBeDrawn; + while (counter > 0) + { + int x = r.Next(1, numberOfBalls + 1); + if (!list.Contains(x)) + { + list.Add(x); + counter--; + } + } + if (sort) + { + list.Sort(); + } + return list; + } +} diff --git a/Reflection/LottoImplementation/LottoImplementation.csproj b/Reflection/LottoImplementation/LottoImplementation.csproj new file mode 100644 index 0000000..1741304 --- /dev/null +++ b/Reflection/LottoImplementation/LottoImplementation.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/Reflection/LottoInterfaces/ILotto.cs b/Reflection/LottoInterfaces/ILotto.cs new file mode 100644 index 0000000..48a6c3a --- /dev/null +++ b/Reflection/LottoInterfaces/ILotto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace LottoInterfaces; + +public interface ILotto +{ + List DrawNumbers(bool sort); +} diff --git a/Reflection/LottoInterfaces/LottoInterfaces.csproj b/Reflection/LottoInterfaces/LottoInterfaces.csproj new file mode 100644 index 0000000..b2c1cc7 --- /dev/null +++ b/Reflection/LottoInterfaces/LottoInterfaces.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + enable + + + diff --git a/Reflection/Reflection.slnx b/Reflection/Reflection.slnx new file mode 100644 index 0000000..6a6e85c --- /dev/null +++ b/Reflection/Reflection.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Reflection/Reflection/Program.cs b/Reflection/Reflection/Program.cs new file mode 100644 index 0000000..17b7e73 --- /dev/null +++ b/Reflection/Reflection/Program.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Loader; +using LottoInterfaces; + +string assemblyLocation = @"../../../../LottoImplementation/bin/Debug/net10.0/LottoImplementation.dll"; +Console.WriteLine("Loading dll from file: " + Path.GetFullPath(assemblyLocation)); +AssemblyLoadContext loadctx = AssemblyLoadContext.Default; +Assembly asm = loadctx.LoadFromAssemblyPath(Path.GetFullPath(assemblyLocation)); +Type? type = asm.GetType("LottoImplementation.Lotto"); +Debug.Assert(type != null, "LottoImplementation.Lotto not found!"); +PrintData(type); + +Console.WriteLine(); +Console.WriteLine("Creating an instance..."); +object? obj = Activator.CreateInstance(type, 7, 39); +Debug.Assert(obj != null, "Could not create an instance of LottoImplementation.Lotto"); + +MethodInfo? info = type.GetMethod("DrawNumbers"); +Debug.Assert(info != null, "DrawNumbers method not found!"); + +object? result = info.Invoke(obj, new object[] { false }); +Debug.Assert(result != null && result is List, "DrawNumbers returned null or not of type List"); + +string print = string.Join(", ", (List)result); +Console.WriteLine(print); + +//draw again, but this time sorted +result = info.Invoke(obj, new object[] { true }); +print = string.Join(", ", (result as List)!); +Console.WriteLine(print); + +//we know that the class implements ILotto +ILotto loto = (ILotto)obj; +List numbers = loto.DrawNumbers(true); +print = string.Join(", ", numbers); +Console.WriteLine(print); + + +static void PrintData(Type t) +{ + ListVariousStats(t); + ListFields(t); + ListProps(t); + ListMethods(t); + ListInterfaces(t); +} + +static void ListVariousStats(Type t) +{ + Console.WriteLine("***** Various Statistics *****"); + Console.WriteLine("Base class is: {0}", t.BaseType); + Console.WriteLine("Is type abstract? {0}", t.IsAbstract); + Console.WriteLine("Is type sealed? {0}", t.IsSealed); + Console.WriteLine("Is type generic? {0}", t.IsGenericTypeDefinition); + Console.WriteLine("Is type a class type? {0}", t.IsClass); + Console.WriteLine(); +} + +static void ListInterfaces(Type t) +{ + Console.WriteLine("***** Interfaces *****"); + foreach (Type i in t.GetInterfaces()) + Console.WriteLine("->{0}", i.Name); +} + +static void ListProps(Type t) +{ + Console.WriteLine("***** Properties *****"); + foreach (PropertyInfo info in t.GetProperties()) + Console.WriteLine("->{0}", info); + Console.WriteLine(); +} + +static void ListFields(Type t) +{ + Console.WriteLine("***** Fields *****"); + //samo GetField() će vratiti samo javne varijable + foreach (FieldInfo field in t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)) + Console.WriteLine("->{0}", field); + Console.WriteLine(); +} + +static void ListMethods(Type t) +{ + Console.WriteLine("***** Methods *****"); + MethodInfo[] mi = t.GetMethods(); + foreach (MethodInfo m in mi) + Console.WriteLine("->{0}", m); + Console.WriteLine(); +} + diff --git a/Reflection/Reflection/Reflection.csproj b/Reflection/Reflection/Reflection.csproj new file mode 100644 index 0000000..49aa02d --- /dev/null +++ b/Reflection/Reflection/Reflection.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/ReflectionBenchmark/Events.EF/Data/MSSQL/EventsContext.cs b/ReflectionBenchmark/Events.EF/Data/MSSQL/EventsContext.cs new file mode 100644 index 0000000..df5e7a7 --- /dev/null +++ b/ReflectionBenchmark/Events.EF/Data/MSSQL/EventsContext.cs @@ -0,0 +1,143 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.EF.Data.MSSQL; + +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); + + entity.ToTable("Country"); + + entity.HasIndex(e => e.Name, "UQ_Country_Name").IsUnique(); + + entity.Property(e => e.Code) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.Alpha3) + .HasMaxLength(3) + .IsUnicode(false) + .IsFixedLength(); + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Event"); + + entity.Property(e => e.Name) + .HasMaxLength(150) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Person"); + + entity.HasIndex(e => new { e.DocumentNumber, e.CountryCode }, "UQ_Person_DocumentNumber_CountryCode").IsUnique(); + + entity.Property(e => e.AddressCountry) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.AddressLine) + .HasMaxLength(200) + .IsUnicode(false); + entity.Property(e => e.City) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.ContactPhone) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.CountryCode) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.DocumentNumber) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.Email) + .HasMaxLength(255) + .IsUnicode(false); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.FirstNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.PostalCode) + .HasMaxLength(20) + .IsUnicode(false); + + entity.HasOne(d => d.CountryCodeNavigation).WithMany(p => p.People) + .HasForeignKey(d => d.CountryCode) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Person_Country"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Registration"); + + entity.HasIndex(e => new { e.PersonId, e.SportId, e.EventId }, "UQ_Registration_PersonId_SportId_EventId").IsUnique(); + + entity.Property(e => e.RegisteredAt).HasDefaultValueSql("(sysutcdatetime())", "DF_Registration_RegisteredAt"); + + entity.HasOne(d => d.Event).WithMany(p => p.Registrations) + .HasForeignKey(d => d.EventId) + .HasConstraintName("FK_Registration_Event"); + + entity.HasOne(d => d.Person).WithMany(p => p.Registrations) + .HasForeignKey(d => d.PersonId) + .HasConstraintName("FK_Registration_Person"); + + entity.HasOne(d => d.Sport).WithMany(p => p.Registrations) + .HasForeignKey(d => d.SportId) + .HasConstraintName("FK_Registration_Sport"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Sport"); + + entity.HasIndex(e => e.Name, "UQ_Sport_Name").IsUnique(); + + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/ReflectionBenchmark/Events.EF/Data/Postgres/EventsContext.cs b/ReflectionBenchmark/Events.EF/Data/Postgres/EventsContext.cs new file mode 100644 index 0000000..1e66779 --- /dev/null +++ b/ReflectionBenchmark/Events.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.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.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/ReflectionBenchmark/Events.EF/Events.EF.csproj b/ReflectionBenchmark/Events.EF/Events.EF.csproj new file mode 100644 index 0000000..7cae43e --- /dev/null +++ b/ReflectionBenchmark/Events.EF/Events.EF.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/ReflectionBenchmark/Events.EF/Models/Country.cs b/ReflectionBenchmark/Events.EF/Models/Country.cs new file mode 100644 index 0000000..3203a07 --- /dev/null +++ b/ReflectionBenchmark/Events.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.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/ReflectionBenchmark/Events.EF/Models/Event.cs b/ReflectionBenchmark/Events.EF/Models/Event.cs new file mode 100644 index 0000000..13b99f6 --- /dev/null +++ b/ReflectionBenchmark/Events.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.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/ReflectionBenchmark/Events.EF/Models/Person.cs b/ReflectionBenchmark/Events.EF/Models/Person.cs new file mode 100644 index 0000000..c6df0ab --- /dev/null +++ b/ReflectionBenchmark/Events.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.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/ReflectionBenchmark/Events.EF/Models/Registration.cs b/ReflectionBenchmark/Events.EF/Models/Registration.cs new file mode 100644 index 0000000..460d2b1 --- /dev/null +++ b/ReflectionBenchmark/Events.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.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/ReflectionBenchmark/Events.EF/Models/Sport.cs b/ReflectionBenchmark/Events.EF/Models/Sport.cs new file mode 100644 index 0000000..8b126d6 --- /dev/null +++ b/ReflectionBenchmark/Events.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.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(); +} \ No newline at end of file diff --git a/ReflectionBenchmark/Events.EF/efpt.mssql.config.json b/ReflectionBenchmark/Events.EF/efpt.mssql.config.json new file mode 100644 index 0000000..6df4a7a --- /dev/null +++ b/ReflectionBenchmark/Events.EF/efpt.mssql.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/MSSQL", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "[dbo].[Country]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Event]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Person]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Registration]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Sport]", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/ReflectionBenchmark/Events.EF/efpt.postgres.config.json b/ReflectionBenchmark/Events.EF/efpt.postgres.config.json new file mode 100644 index 0000000..38b602d --- /dev/null +++ b/ReflectionBenchmark/Events.EF/efpt.postgres.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/Postgres", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "public.country", + "ObjectType": 0 + }, + { + "Name": "public.event", + "ObjectType": 0 + }, + { + "Name": "public.person", + "ObjectType": 0 + }, + { + "Name": "public.registration", + "ObjectType": 0 + }, + { + "Name": "public.sport", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/ReflectionBenchmark/ReflectionBenchmark.slnx b/ReflectionBenchmark/ReflectionBenchmark.slnx new file mode 100644 index 0000000..df23931 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/ReflectionBenchmark/ReflectionBenchmark/CompiledMapper.cs b/ReflectionBenchmark/ReflectionBenchmark/CompiledMapper.cs new file mode 100644 index 0000000..7ab3de3 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/CompiledMapper.cs @@ -0,0 +1,45 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace ReflectionBenchmark; + +public static class CompiledMapper +{ + public static Func Create() + where TDest : new() + { + var sourceParam = Expression.Parameter(typeof(TSource), "src"); + + var sourceProps = typeof(TSource) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + + var destProps = typeof(TDest) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + + var bindings = new List(); + + foreach (var destProp in destProps) + { + var sourceProp = sourceProps + .FirstOrDefault(p => p.Name == destProp.Name && + p.PropertyType == destProp.PropertyType); + + if (sourceProp == null) + continue; + + var sourceAccess = Expression.Property(sourceParam, sourceProp); + + var bind = Expression.Bind(destProp, sourceAccess); + + bindings.Add(bind); + } + + var newDest = Expression.New(typeof(TDest)); + + var body = Expression.MemberInit(newDest, bindings); + + var lambda = Expression.Lambda>(body, sourceParam); + + return lambda.Compile(); + } +} \ No newline at end of file diff --git a/ReflectionBenchmark/ReflectionBenchmark/DBAccess.cs b/ReflectionBenchmark/ReflectionBenchmark/DBAccess.cs new file mode 100644 index 0000000..3ca7ddb --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/DBAccess.cs @@ -0,0 +1,124 @@ +using AutoMapper; +using BenchmarkDotNet.Attributes; +using Events.EF.Data.Postgres; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; + +namespace ReflectionBenchmark; + +[MemoryDiagnoser] +public class DBAccess +{ + IMapper mapper = null!; + IHost host = null!; + IDbContextFactory dbContextFactory = null!; + string connectionString = string.Empty; + + [Params(1, 10, 1000, 10000)] + public int Top { get; set; } + + [GlobalSetup] + public void PrepareMappings() + { + host = DISetup.BuildHost([]); + dbContextFactory = host.Services.GetRequiredService>(); + + var config = new MapperConfiguration(cfg => cfg.CreateMap() + .ForMember(d => d.FirstName, + opt => opt.MapFrom(e => e.FirstNameTranscription)) + .ForMember(d => d.LastName, + opt => opt.MapFrom(e => e.LastNameTranscription)) + .ForMember(d => d.Country, + opt => opt.MapFrom(e => e.CountryCodeNavigation.Name)), + NullLoggerFactory.Instance); + + mapper = new Mapper(config); + connectionString = host.Services.GetRequiredService().GetConnectionString("EventsPostgres") + ?? throw new InvalidOperationException("Missing connection string 'EventsPostgres'."); + } + + [GlobalCleanup] + public void Cleanup() + { + host.Dispose(); + } + + /// + /// Load top Top people ordered by last name, and then first name + /// storing them in PersonDest manually + /// + /// + [Benchmark] + public List MapManually() + { + using var ctx = dbContextFactory.CreateDbContext(); + var query = ctx.People + .OrderBy(p => p.LastNameTranscription) + .ThenBy(p => p.FirstNameTranscription) + .Take(Top) + .Select(p => new PersonDest + { + FirstName = p.FirstNameTranscription, + LastName = p.LastNameTranscription, + Country = p.CountryCodeNavigation.Name + }); + + var list = query.ToList(); + return list; + } + + [Benchmark(Baseline = true)] + public List AdoNet() + { + List list = new(); + using var connection = new NpgsqlConnection(connectionString); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT person.first_name_transcription, person.last_name_transcription, country.name + FROM person + INNER JOIN country ON country.code = person.country_code + ORDER BY person.last_name_transcription, person.first_name_transcription + LIMIT @top + """; + command.CommandType = System.Data.CommandType.Text; + command.Parameters.AddWithValue("top", Top); + connection.Open(); + using var reader = command.ExecuteReader(); + while(reader.Read()) + { + list.Add(new PersonDest + { + FirstName = reader.GetString(0), + LastName = reader.GetString(1), + Country = reader.GetString(2) + }); + } + + return list; + } + + /// + /// Load top Top people ordered by last name, and then first name + /// storing them in PersonDest using AutoMapper + /// + /// + [Benchmark] + public List MapAutoMapper() + { + using var ctx = dbContextFactory.CreateDbContext(); + var query = ctx.People + .OrderBy(p => p.LastNameTranscription) + .ThenBy(p => p.FirstNameTranscription) + .Take(Top); + + IQueryable projectedQuery = mapper.ProjectTo(query); + + var list = projectedQuery.ToList(); + return list; + } +} diff --git a/ReflectionBenchmark/ReflectionBenchmark/DISetup.cs b/ReflectionBenchmark/ReflectionBenchmark/DISetup.cs new file mode 100644 index 0000000..f157330 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/DISetup.cs @@ -0,0 +1,21 @@ + +using Events.EF.Data.Postgres; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ReflectionBenchmark; + +internal static class DISetup +{ + public static IHost BuildHost(string[] args) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + + builder.Services.AddDbContextFactory(options => { + options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres")); + }); + return builder.Build(); + } +} diff --git a/ReflectionBenchmark/ReflectionBenchmark/PersonDest.cs b/ReflectionBenchmark/ReflectionBenchmark/PersonDest.cs new file mode 100644 index 0000000..109a787 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/PersonDest.cs @@ -0,0 +1,9 @@ +namespace ReflectionBenchmark; + +public record PersonDest +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public DateTime Birthday { get; set; } +} diff --git a/ReflectionBenchmark/ReflectionBenchmark/PersonSource.cs b/ReflectionBenchmark/ReflectionBenchmark/PersonSource.cs new file mode 100644 index 0000000..efd65e4 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/PersonSource.cs @@ -0,0 +1,9 @@ +namespace ReflectionBenchmark; + +public class PersonSource +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public DateTime Birthday { get; set; } +} diff --git a/ReflectionBenchmark/ReflectionBenchmark/PersonUtil.cs b/ReflectionBenchmark/ReflectionBenchmark/PersonUtil.cs new file mode 100644 index 0000000..1c3b8b6 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/PersonUtil.cs @@ -0,0 +1,202 @@ +using System.Reflection; +using AutoMapper; +using BenchmarkDotNet.Attributes; +using Bogus; +using Microsoft.Extensions.Logging.Abstractions; + + +namespace ReflectionBenchmark; + +[MemoryDiagnoser] +public class PersonUtil +{ + Mapper mapper = null!; + Faker faker = null!; + List source = null!; + public record Pair(T DestProp, U SourceProp); + static List> mapping = new(); + static Dictionary> getters = new(); + static Dictionary> setters = new(); + static Func compiledMapper = null!; + + public record AccessPair( + Func Getter, + Action Setter); + static List accessorMapping = new(); + + [Params(1000)] + public int ListSize { get; set; } + + [GlobalSetup] + public void PrepareMappingsAndData() + { + var config = new MapperConfiguration(cfg => cfg.CreateMap(), + NullLoggerFactory.Instance); + mapper = new Mapper(config); + + faker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()) + .RuleFor(p => p.Country, f => f.Address.Country()) + .RuleFor(p => p.Birthday, f => f.Date.Between(new DateTime(1900), DateTime.Now)); + + source = faker.Generate(ListSize); + + Type sourceType = typeof(PersonSource); + if (getters.Count == 0) + { + getters = PropertyAccessorCache.CreateAccessors(); + + setters = PropertyAccessorCache.CreateSetters(); + Type destType = typeof(PersonDest); + + PropertyInfo[] sourceProperties = sourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + PropertyInfo[] destProperties = destType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (PropertyInfo destProperty in destProperties) + { + PropertyInfo? sourceProperty = sourceProperties.FirstOrDefault(d => d.Name == destProperty.Name); + if (sourceProperty != null) + { + mapping.Add(new Pair(destProperty, sourceProperty)); + } + + if (getters.TryGetValue(destProperty.Name, out var getter) && + setters.TryGetValue(destProperty.Name, out var setter)) + { + accessorMapping.Add(new AccessPair(getter, setter)); + } + } + compiledMapper = CompiledMapper.Create(); + } + } + + /// + /// Copy source list to destination list converting each object to an object of another type + /// "Conversion" is done line by line in foreach loop + /// + /// + [Benchmark] + public List MapForeach() + { + List dest = new List(source.Count); + foreach (var item in source) + { + dest.Add(new PersonDest + { + FirstName = item.FirstName, + LastName = item.LastName, + Country = item.Country, + Birthday = item.Birthday + }); + } + return dest; + } + + [Benchmark(Baseline = true)] + public List MapFor() + { + List dest = new List(source.Count); + for (int i = 0; i < source.Count; i++) + { + PersonSource item = source[i]; + dest.Add(new PersonDest + { + FirstName = item.FirstName, + LastName = item.LastName, + Country = item.Country, + Birthday = item.Birthday + }); + } + return dest; + } + + /// + /// Copy source list to destination list converting each object to an object of another type + /// It is done using Linq instead of foreach loop + /// + /// + [Benchmark] + public List MapLinq() + { + return source.Select(s => new PersonDest + { + FirstName = s.FirstName, + LastName = s.LastName, + Country = s.Country, + Birthday = s.Birthday + }).ToList(); + } + + /// + /// Copy source list to destination list converting each object to an object of another type + /// "Conversion" is done using reflection, where mapping is established in each test iteration + /// + /// + [Benchmark] + public List MapWithReflection() + { + List dest = new List(source.Count); + + foreach (var sourceItem in source) + { + PersonDest personDest = new (); + foreach (var pair in mapping) + { + pair.DestProp.SetValue(personDest, pair.SourceProp.GetValue(sourceItem)); + } + dest.Add(personDest); + } + return dest; + } + + /// + /// Copy source list to destination list converting each object to an object of another type + /// "Conversion" is done using reflection, where mapping is established in each test iteration + /// + /// + [Benchmark] + public List WithPropertyAccessor() + { + List dest = new List(source.Count); + + foreach (var sourceItem in source) + { + PersonDest personDest = new(); + foreach (var pair in accessorMapping) + { + pair.Setter(personDest, pair.Getter(sourceItem)); + } + dest.Add(personDest); + } + return dest; + } + + [Benchmark] + public List MapWithCompiledExpression() + { + List dest = new(source.Count); + + foreach (var item in source) + { + dest.Add(compiledMapper(item)); + } + + return dest; + } + + /// + /// Copy source list to destination list converting each object to an object of another type + /// "Conversion" is done with AutoMapper (library made for this purpose) + /// + /// + [Benchmark] + public List MapWithAutoMapper() + { + List dest = new List(source.Count); + foreach (var item in source) + { + dest.Add(mapper.Map(item)); + } + return dest; + } +} diff --git a/ReflectionBenchmark/ReflectionBenchmark/Program.cs b/ReflectionBenchmark/ReflectionBenchmark/Program.cs new file mode 100644 index 0000000..271c4aa --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/Program.cs @@ -0,0 +1,7 @@ +// See https://aka.ms/new-console-template for more information +using BenchmarkDotNet.Running; +using ReflectionBenchmark; + +var summary = BenchmarkRunner.Run(); +//var summary = BenchmarkRunner.Run(); + diff --git a/ReflectionBenchmark/ReflectionBenchmark/PropertyAccessorCache.cs b/ReflectionBenchmark/ReflectionBenchmark/PropertyAccessorCache.cs new file mode 100644 index 0000000..5cc9638 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/PropertyAccessorCache.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace ReflectionBenchmark; +public class PropertyAccessorCache +{ + + public static Dictionary> CreateAccessors() + { + var dict = new Dictionary>(); + + foreach (var prop in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!prop.CanRead) continue; + + var param = Expression.Parameter(typeof(T)); + + var property = Expression.Property(param, prop); + var convert = Expression.Convert(property, typeof(object)); + + var lambda = Expression.Lambda>(convert, param).Compile(); + + dict[prop.Name] = lambda; + } + + return dict; + } + + public static Dictionary> CreateSetters() + { + var dict = new Dictionary>(); + + foreach (var prop in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!prop.CanWrite) continue; + + var instanceParam = Expression.Parameter(typeof(T), "instance"); + var valueParam = Expression.Parameter(typeof(object), "value"); + + var property = Expression.Property(instanceParam, prop); + + var valueCast = Expression.Convert(valueParam, prop.PropertyType); + + var assign = Expression.Assign(property, valueCast); + + var lambda = Expression + .Lambda>(assign, instanceParam, valueParam) + .Compile(); + + dict[prop.Name] = lambda; + } + + return dict; + } +} \ No newline at end of file diff --git a/ReflectionBenchmark/ReflectionBenchmark/ReflectionBenchmark.csproj b/ReflectionBenchmark/ReflectionBenchmark/ReflectionBenchmark.csproj new file mode 100644 index 0000000..fabb007 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/ReflectionBenchmark.csproj @@ -0,0 +1,38 @@ + + + + Exe + net10.0 + enable + enable + + + + PI + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/ReflectionBenchmark/ReflectionBenchmark/appsettings.json b/ReflectionBenchmark/ReflectionBenchmark/appsettings.json new file mode 100644 index 0000000..a19bdd8 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=sport;Password=pogledaj u user secrets;Persist Security Info=True" + }, + "Logging": { + "LogLevel": { + "Default": "Error", + "System": "Error", + "Microsoft": "Error", + "Microsoft.EntityFrameworkCore.Database.Command" : "Error" + } + } +} \ No newline at end of file diff --git a/ReflectionBenchmark/ReflectionBenchmark/results.txt b/ReflectionBenchmark/ReflectionBenchmark/results.txt new file mode 100644 index 0000000..b5e8b61 --- /dev/null +++ b/ReflectionBenchmark/ReflectionBenchmark/results.txt @@ -0,0 +1,144 @@ +BenchmarkDotNet=v0.13.3, OS=Windows 11 (10.0.22621.963) +11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores +.NET SDK=7.0.101 + [Host] : .NET 6.0.12 (6.0.1222.56807), X64 RyuJIT AVX2 + DefaultJob : .NET 6.0.12 (6.0.1222.56807), X64 RyuJIT AVX2 + + +| Method | ListSize | Mean | Error | StdDev | Ratio | RatioSD | +|------------------ |--------- |-----------:|---------:|----------:|------:|--------:| +| MapForeach | 10 | 108.5 ns | 2.17 ns | 2.50 ns | 1.00 | 0.00 | +| MapLinq | 10 | 120.2 ns | 2.26 ns | 2.12 ns | 1.11 | 0.03 | +| MapWithReflection | 10 | 4,818.1 ns | 95.43 ns | 194.94 ns | 45.30 | 2.68 | +| MapWithAutoMapper | 10 | 546.3 ns | 10.90 ns | 12.12 ns | 5.04 | 0.15 | + + + +BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.100 + [Host] : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2 + DefaultJob : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2 + + +| Method | ListSize | Mean | Error | StdDev | Ratio | RatioSD | +|------------------ |--------- |------------:|----------:|---------:|------:|--------:| +| MapForeach | 10 | 83.14 ns | 0.907 ns | 0.848 ns | 1.00 | 0.00 | +| MapLinq | 10 | 117.53 ns | 1.680 ns | 1.571 ns | 1.41 | 0.03 | +| MapWithReflection | 10 | 3,042.57 ns | 12.641 ns | 9.869 ns | 36.57 | 0.35 | +| MapWithAutoMapper | 10 | 499.42 ns | 3.913 ns | 3.469 ns | 6.00 | 0.08 | + + +BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.100 + [Host] : .NET 7.0.14 (7.0.1423.51910), X64 RyuJIT AVX2 + DefaultJob : .NET 7.0.14 (7.0.1423.51910), X64 RyuJIT AVX2 + + +| Method | ListSize | Mean | Error | StdDev | Ratio | RatioSD | +|------------------ |--------- |------------:|----------:|---------:|------:|--------:| +| MapForeach | 10 | 84.94 ns | 0.814 ns | 0.680 ns | 1.00 | 0.00 | +| MapLinq | 10 | 111.17 ns | 2.226 ns | 2.286 ns | 1.31 | 0.03 | +| MapWithReflection | 10 | 1,008.92 ns | 10.223 ns | 9.562 ns | 11.87 | 0.14 | +| MapWithAutoMapper | 10 | 504.64 ns | 5.971 ns | 5.585 ns | 5.94 | 0.07 | + + + +BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.100 + [Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 + + +| Method | ListSize | Mean | Error | StdDev | Ratio | RatioSD | +|------------------ |--------- |----------:|----------:|---------:|------:|--------:| +| MapForeach | 10 | 80.67 ns | 0.663 ns | 0.554 ns | 1.00 | 0.00 | +| MapLinq | 10 | 107.38 ns | 2.131 ns | 1.993 ns | 1.33 | 0.03 | +| MapWithReflection | 10 | 668.54 ns | 10.255 ns | 9.593 ns | 8.30 | 0.14 | +| MapWithAutoMapper | 10 | 424.24 ns | 6.710 ns | 5.603 ns | 5.26 | 0.08 | + + + +BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4460/23H2/2023Update/SunValley3) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.404 + [Host] : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2 + + +| Method | ListSize | Mean | Error | StdDev | Ratio | RatioSD | +|------------------ |--------- |----------:|---------:|---------:|------:|--------:| +| MapForeach | 10 | 78.10 ns | 0.764 ns | 0.715 ns | 1.00 | 0.01 | +| MapLinq | 10 | 108.61 ns | 0.945 ns | 0.838 ns | 1.39 | 0.02 | +| MapWithReflection | 10 | 641.86 ns | 6.721 ns | 6.287 ns | 8.22 | 0.11 | +| MapWithAutoMapper | 10 | 418.63 ns | 2.366 ns | 2.098 ns | 5.36 | 0.05 | + + +-------------------------- +Database in local container: + + BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.100 + [Host] : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2 + DefaultJob : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2 + + +| Method | Top | Mean | Error | StdDev | Ratio | RatioSD | +|-------------- |---- |---------:|----------:|----------:|------:|--------:| +| MapManually | 10 | 3.391 ms | 0.2923 ms | 0.8527 ms | 1.00 | 0.00 | +| MapAutoMapper | 10 | 3.223 ms | 0.3683 ms | 1.0801 ms | 1.00 | 0.39 | + + +BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.100 + [Host] : .NET 7.0.14 (7.0.1423.51910), X64 RyuJIT AVX2 + DefaultJob : .NET 7.0.14 (7.0.1423.51910), X64 RyuJIT AVX2 + + +| Method | Top | Mean | Error | StdDev | Ratio | RatioSD | +|-------------- |---- |---------:|----------:|----------:|------:|--------:| +| MapManually | 10 | 3.283 ms | 0.3443 ms | 1.0044 ms | 1.00 | 0.00 | +| MapAutoMapper | 10 | 2.824 ms | 0.3019 ms | 0.8757 ms | 0.95 | 0.43 | + +BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4460/23H2/2023Update/SunValley3) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 8.0.404 + [Host] : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2 + + +| Method | Top | Mean | Error | StdDev | Ratio | RatioSD | +|-------------- |---- |---------:|----------:|----------:|------:|--------:| +| MapManually | 10 | 1.143 ms | 0.0227 ms | 0.0651 ms | 1.00 | 0.08 | +| MapAutoMapper | 10 | 1.077 ms | 0.0213 ms | 0.0351 ms | 0.95 | 0.06 | + + + +BenchmarkDotNet v0.14.0, Windows 11 (10.0.26200.7171) +13th Gen Intel Core i5-1340P, 1 CPU, 16 logical and 12 physical cores +.NET SDK 9.0.308 + [Host] : .NET 8.0.22 (8.0.2225.52707), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.22 (8.0.2225.52707), X64 RyuJIT AVX2 + + +| Method | Top | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|-------------- |------ |-----------:|---------:|----------:|-----------:|------:|--------:|--------:|-------:|----------:|------------:| +| MapManually | 1 | 1,562.0 us | 89.21 us | 261.63 us | 1,530.7 us | 1.94 | 0.39 | 11.7188 | 3.9063 | 90.31 KB | 31.23 | +| AdoNet | 1 | 818.2 us | 37.96 us | 103.93 us | 802.5 us | 1.01 | 0.17 | - | - | 2.89 KB | 1.00 | +| MapAutoMapper | 1 | 1,258.5 us | 29.91 us | 82.87 us | 1,234.8 us | 1.56 | 0.20 | 13.6719 | 3.9063 | 88.38 KB | 30.57 | +| | | | | | | | | | | | | +| MapManually | 10 | 1,313.9 us | 48.35 us | 132.35 us | 1,294.8 us | 1.65 | 0.18 | 11.7188 | 3.9063 | 93.7 KB | 19.69 | +| AdoNet | 10 | 796.4 us | 15.77 us | 39.57 us | 788.2 us | 1.00 | 0.07 | - | - | 4.76 KB | 1.00 | +| MapAutoMapper | 10 | 1,335.9 us | 44.21 us | 122.50 us | 1,317.6 us | 1.68 | 0.17 | 11.7188 | 3.9063 | 92.04 KB | 19.34 | +| | | | | | | | | | | | | +| MapManually | 1000 | 1,750.7 us | 66.48 us | 186.41 us | 1,716.0 us | 1.27 | 0.15 | 23.4375 | 3.9063 | 157.96 KB | 4.15 | +| AdoNet | 1000 | 1,387.4 us | 30.22 us | 82.72 us | 1,374.0 us | 1.00 | 0.08 | 5.8594 | - | 38.09 KB | 1.00 | +| MapAutoMapper | 1000 | 1,746.5 us | 49.52 us | 134.71 us | 1,729.9 us | 1.26 | 0.12 | 23.4375 | 3.9063 | 156.25 KB | 4.10 | +| | | | | | | | | | | | | +| MapManually | 10000 | 1,750.1 us | 70.01 us | 198.61 us | 1,707.3 us | 1.28 | 0.17 | 23.4375 | 3.9063 | 157.95 KB | 4.15 | +| AdoNet | 10000 | 1,374.6 us | 33.90 us | 95.63 us | 1,366.8 us | 1.00 | 0.10 | 5.8594 | - | 38.09 KB | 1.00 | +| MapAutoMapper | 10000 | 1,736.7 us | 42.34 us | 116.63 us | 1,726.7 us | 1.27 | 0.12 | 23.4375 | 3.9063 | 156.24 KB | 4.10 | \ No newline at end of file