diff --git a/package-lock.json b/package-lock.json index 6611c60..922eecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "axios": "^1.3.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "durable-functions": "^3.0.0-alpha.4", + "durable-functions": "^3.0.0", "env-var": "^7.3.0", "framer-motion": "^10.8.5", "js-cookie": "^3.0.1", @@ -97,9 +97,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -5523,12 +5523,12 @@ } }, "node_modules/@nrwl/nx-cloud": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/@nrwl/nx-cloud/-/nx-cloud-16.0.5.tgz", - "integrity": "sha512-1p82ym8WE9ziejwgPslstn19iV/VkHfHfKr/5YOnfCHQS+NxUf92ogcYhHXtqWLblVZ9Zs4W4pkSXK4e04wCmQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@nrwl/nx-cloud/-/nx-cloud-18.0.0.tgz", + "integrity": "sha512-rjjcJgzDmKwFD1QVIMs5O3X4SoMQIk0bzh3pL90ZP/B5YJUlTySv7+R0JoGQ6ROGwVQHjPFMVKKLB09zl5perA==", "dev": true, "dependencies": { - "nx-cloud": "16.0.5" + "nx-cloud": "18.0.0" } }, "node_modules/@nrwl/react": { @@ -8666,11 +8666,11 @@ } }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -11309,12 +11309,12 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" }, "node_modules/durable-functions": { - "version": "3.0.0-alpha.5", - "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.0.0-alpha.5.tgz", - "integrity": "sha512-ci8HCVg9HNPjpFAiVfdQONdOaeJ/HtMu0I+zTk4Udkp2Yk2bnM+mS362gXbrXvpwdRC6Ew/u0owO+oHJK9vHwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.1.0.tgz", + "integrity": "sha512-baSkW45/VrVvl1e27qvxEZX2z5vApiINaGUTIxx0c4H5E2Lr22QnogZrebqIhyfspZ466XKhp245nyS0HSkAYg==", "dependencies": { - "@azure/functions": "^4.0.0-alpha.8", - "axios": "^0.21.1", + "@azure/functions": "^4.0.0", + "axios": "^1.6.1", "debug": "~2.6.9", "lodash": "^4.17.15", "moment": "^2.29.2", @@ -11325,14 +11325,6 @@ "node": ">=18.0" } }, - "node_modules/durable-functions/node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dependencies": { - "follow-redirects": "^1.14.0" - } - }, "node_modules/durable-functions/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -13092,9 +13084,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -17442,12 +17434,12 @@ } }, "node_modules/nx-cloud": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/nx-cloud/-/nx-cloud-16.0.5.tgz", - "integrity": "sha512-13P7r0aKikjBtmdZrNorwXzVPeVIV4MLEwqGY+DEG6doLBtI5KqEQk/d5B5l2dCF2BEi/LXEmLYCmf9gwbOJ+Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/nx-cloud/-/nx-cloud-18.0.0.tgz", + "integrity": "sha512-VpPywcHmFIU3GSWb3KV3nQ+TAMLc06DTO39vTFsM+HreB6qRloDxbADRvfM5eHAbY26TNmwflT7wxd0fluv2+A==", "dev": true, "dependencies": { - "@nrwl/nx-cloud": "16.0.5", + "@nrwl/nx-cloud": "18.0.0", "axios": "1.1.3", "chalk": "^4.1.0", "dotenv": "~10.0.0", @@ -23199,9 +23191,9 @@ } }, "node_modules/undici": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.0.tgz", - "integrity": "sha512-l3ydWhlhOJzMVOYkymLykcRRXqbUaQriERtR70B9LzNkZ4bX52Fc8wbTDneMiwo8T+AemZXvXaTx+9o5ROxrXg==", + "version": "5.28.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", + "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -23785,9 +23777,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", @@ -24767,9 +24759,9 @@ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==" }, "@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==" }, "@alloc/quick-lru": { "version": "5.2.0", @@ -28572,12 +28564,12 @@ } }, "@nrwl/nx-cloud": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/@nrwl/nx-cloud/-/nx-cloud-16.0.5.tgz", - "integrity": "sha512-1p82ym8WE9ziejwgPslstn19iV/VkHfHfKr/5YOnfCHQS+NxUf92ogcYhHXtqWLblVZ9Zs4W4pkSXK4e04wCmQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@nrwl/nx-cloud/-/nx-cloud-18.0.0.tgz", + "integrity": "sha512-rjjcJgzDmKwFD1QVIMs5O3X4SoMQIk0bzh3pL90ZP/B5YJUlTySv7+R0JoGQ6ROGwVQHjPFMVKKLB09zl5perA==", "dev": true, "requires": { - "nx-cloud": "16.0.5" + "nx-cloud": "18.0.0" } }, "@nrwl/react": { @@ -30838,11 +30830,11 @@ "integrity": "sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==" }, "axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", "requires": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -32787,12 +32779,12 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" }, "durable-functions": { - "version": "3.0.0-alpha.5", - "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.0.0-alpha.5.tgz", - "integrity": "sha512-ci8HCVg9HNPjpFAiVfdQONdOaeJ/HtMu0I+zTk4Udkp2Yk2bnM+mS362gXbrXvpwdRC6Ew/u0owO+oHJK9vHwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.1.0.tgz", + "integrity": "sha512-baSkW45/VrVvl1e27qvxEZX2z5vApiINaGUTIxx0c4H5E2Lr22QnogZrebqIhyfspZ466XKhp245nyS0HSkAYg==", "requires": { - "@azure/functions": "^4.0.0-alpha.8", - "axios": "^0.21.1", + "@azure/functions": "^4.0.0", + "axios": "^1.6.1", "debug": "~2.6.9", "lodash": "^4.17.15", "moment": "^2.29.2", @@ -32800,14 +32792,6 @@ "validator": "~13.7.0" }, "dependencies": { - "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "requires": { - "follow-redirects": "^1.14.0" - } - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -34135,9 +34119,9 @@ } }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, "for-each": { "version": "0.3.3", @@ -37335,12 +37319,12 @@ } }, "nx-cloud": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/nx-cloud/-/nx-cloud-16.0.5.tgz", - "integrity": "sha512-13P7r0aKikjBtmdZrNorwXzVPeVIV4MLEwqGY+DEG6doLBtI5KqEQk/d5B5l2dCF2BEi/LXEmLYCmf9gwbOJ+Q==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/nx-cloud/-/nx-cloud-18.0.0.tgz", + "integrity": "sha512-VpPywcHmFIU3GSWb3KV3nQ+TAMLc06DTO39vTFsM+HreB6qRloDxbADRvfM5eHAbY26TNmwflT7wxd0fluv2+A==", "dev": true, "requires": { - "@nrwl/nx-cloud": "16.0.5", + "@nrwl/nx-cloud": "18.0.0", "axios": "1.1.3", "chalk": "^4.1.0", "dotenv": "~10.0.0", @@ -41243,9 +41227,9 @@ } }, "undici": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.0.tgz", - "integrity": "sha512-l3ydWhlhOJzMVOYkymLykcRRXqbUaQriERtR70B9LzNkZ4bX52Fc8wbTDneMiwo8T+AemZXvXaTx+9o5ROxrXg==", + "version": "5.28.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", + "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", "requires": { "@fastify/busboy": "^2.0.0" } @@ -41605,9 +41589,9 @@ } }, "webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "requires": { "colorette": "^2.0.10", "memfs": "^3.4.3", diff --git a/package.json b/package.json index ae84c10..2a3668f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "axios": "^1.3.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "durable-functions": "^3.0.0", "env-var": "^7.3.0", "framer-motion": "^10.8.5", "js-cookie": "^3.0.1", diff --git a/packages/client/src/api/hooks/alertHooks.ts b/packages/client/src/api/hooks/alertHooks.ts new file mode 100644 index 0000000..b52bb23 --- /dev/null +++ b/packages/client/src/api/hooks/alertHooks.ts @@ -0,0 +1,9 @@ +import { Alert, PontozoError } from '@pontozo/common' +import { useQuery } from '@tanstack/react-query' +import { functionAxios } from 'src/util/axiosConfig' + +export const useFetchAlerts = () => { + return useQuery(['fetchAlerts'], async () => (await functionAxios.get(`/alerts`)).data, { + retry: false, + }) +} diff --git a/packages/client/src/pages/adminIndex/AdminIndex.page.tsx b/packages/client/src/pages/adminIndex/AdminIndex.page.tsx index 6f5e872..2fce835 100644 --- a/packages/client/src/pages/adminIndex/AdminIndex.page.tsx +++ b/packages/client/src/pages/adminIndex/AdminIndex.page.tsx @@ -1,7 +1,19 @@ -import { Heading, Text } from '@chakra-ui/react' +import { Alert, AlertDescription, AlertIcon, AlertTitle, Heading, HStack, Text, VStack } from '@chakra-ui/react' +import { useFetchAlerts } from 'src/api/hooks/alertHooks' import { HelmetTitle } from 'src/components/commons/HelmetTitle' +import { LoadingSpinner } from 'src/components/commons/LoadingSpinner' +import { NavigateWithError } from 'src/components/commons/NavigateWithError' +import { alertLevelToChakraStatus } from 'src/util/enumHelpers' +import { PATHS } from 'src/util/paths' export const AdminIndex = () => { + const { data, isLoading, error } = useFetchAlerts() + if (isLoading) { + return + } + if (error) { + return + } return ( <> @@ -14,6 +26,28 @@ export const AdminIndex = () => { fogást arra, hogy a haság és az olások elmenek amustól. A fetles ezer empőzs kasolta, hogy a szegséges ülöntés dikás karázsálnia az ehető haságot. + + Figyelmeztetések + + + {data.length > 0 ? ( + data.map((a) => ( + + + + {a.description} + + + {new Date(a.timestamp).toLocaleDateString('hu')} {new Date(a.timestamp).toLocaleTimeString('hu')} + + + )) + ) : ( + + Nincs egy figyelmeztetés se az elmúlt 14 napból! + + )} + ) } diff --git a/packages/client/src/pages/error/error.page.tsx b/packages/client/src/pages/error/error.page.tsx index 712c3d9..64a0856 100644 --- a/packages/client/src/pages/error/error.page.tsx +++ b/packages/client/src/pages/error/error.page.tsx @@ -1,7 +1,5 @@ import { Button, Heading, Text, VStack } from '@chakra-ui/react' -import { Link } from 'react-router-dom' import { HelmetTitle } from 'src/components/commons/HelmetTitle' -import { PATHS } from 'src/util/paths' export const ErrorPage = () => { return ( @@ -9,7 +7,7 @@ export const ErrorPage = () => { Ismeretlen hiba {'Kérlek próbáld újra, és ha a hiba nem hárul el, jelezd a fejlesztőnek a feketesamu{kukac}gmail{pont}hu címen!'} - diff --git a/packages/client/src/pages/ratings/components/SubmitRatingModal.tsx b/packages/client/src/pages/ratings/components/SubmitRatingModal.tsx index 267d434..d29217b 100644 --- a/packages/client/src/pages/ratings/components/SubmitRatingModal.tsx +++ b/packages/client/src/pages/ratings/components/SubmitRatingModal.tsx @@ -46,7 +46,7 @@ export const SubmitRatingModal = ({ variant, color, colorScheme }: Props) => { Köszönjük, hogy értékelted a versenyt! Az értékélest a 'Küldés' gombbal tudod véglegesíteni, ezután már nem fogod tudni - szerkeszteni. Ha szeretnél még szöveges formában visszajelzést küldeni a szervezőknek, azt megteheted az alábbi + szerkeszteni. Ha szeretnél még szöveges formában (névtelen) visszajelzést küldeni a szervezőknek, azt megteheted az alábbi szövegdobozban. diff --git a/packages/client/src/util/enumHelpers.ts b/packages/client/src/util/enumHelpers.ts index 07dd881..ce3ca98 100644 --- a/packages/client/src/util/enumHelpers.ts +++ b/packages/client/src/util/enumHelpers.ts @@ -1,4 +1,4 @@ -import { EventRank, RatingRole, RatingStatus, UserRole } from '@pontozo/common' +import { AlertLevel, EventRank, RatingRole, RatingStatus, UserRole } from '@pontozo/common' type RoleDict = { [K in RatingRole]: string @@ -80,3 +80,13 @@ export const rankColor: RankDict = { [EventRank.FEATURED]: 'red', [EventRank.NONE]: 'gray', } + +type AlertDict = { + [K in AlertLevel]: 'info' | 'warning' | 'error' +} + +export const alertLevelToChakraStatus: AlertDict = { + [AlertLevel.INFO]: 'info', + [AlertLevel.WARN]: 'warning', + [AlertLevel.ERROR]: 'error', +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b75a177..f49e67f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/types/alerts' export * from './lib/types/categories' export * from './lib/types/criteria' export * from './lib/types/criterionRatings' @@ -5,10 +6,11 @@ export * from './lib/types/dbEvents' export * from './lib/types/errors' export * from './lib/types/eventRatings' export * from './lib/types/mtfszEvents' +export * from './lib/types/ratingResult' export * from './lib/types/seasons' export * from './lib/types/uras' export * from './lib/types/users' export * from './lib/types/util' +export * from './lib/util/enumHelpers' export * from './lib/util/filters' export * from './lib/util/getRateableEvents' -export * from './lib/util/ranks' diff --git a/packages/common/src/lib/types/alerts.ts b/packages/common/src/lib/types/alerts.ts new file mode 100644 index 0000000..0f12b81 --- /dev/null +++ b/packages/common/src/lib/types/alerts.ts @@ -0,0 +1,12 @@ +export enum AlertLevel { + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', +} + +export interface Alert { + id: number + description: string + level: AlertLevel + timestamp: Date +} diff --git a/packages/common/src/lib/types/dbEvents.ts b/packages/common/src/lib/types/dbEvents.ts index 10601a9..fba5b3e 100644 --- a/packages/common/src/lib/types/dbEvents.ts +++ b/packages/common/src/lib/types/dbEvents.ts @@ -6,13 +6,21 @@ export interface DbEvent { type: string startDate: string endDate?: string - rateable: boolean + state: EventState highestRank: Rank seasonId: number organisers: Club[] stages?: DbStage[] } +export enum EventState { + RATEABLE = 'RATEABLE', + VALIDATING = 'VALIDATING', + ACCUMULATING = 'ACCUMULATING', + RESULTS_READY = 'RESULTS_READY', + INVALIDATED = 'INVALIDATED', +} + export interface EventWithRating { event: DbEvent userRating?: EventRating & { diff --git a/packages/common/src/lib/types/ratingResult.ts b/packages/common/src/lib/types/ratingResult.ts new file mode 100644 index 0000000..e6826c6 --- /dev/null +++ b/packages/common/src/lib/types/ratingResult.ts @@ -0,0 +1,29 @@ +import { Category } from './categories' +import { Criterion } from './criteria' +import { DbEvent, DbStage } from './dbEvents' + +export interface RatingResult { + id: number + parentId?: number + eventId: number + stageId?: number + criterionId?: number + criterion?: Criterion + categoryId?: number + category?: Category + items: RatingResultItem[] +} + +export interface RatingResultItem { + count: number + average: number + role?: string + ageGroup?: string +} + +export interface EventWithResults extends Omit { + ratingResults: RatingResult + stages: (DbStage & { + ratingResults: RatingResult + })[] +} diff --git a/packages/common/src/lib/types/users.ts b/packages/common/src/lib/types/users.ts index cd361e6..0651f26 100644 --- a/packages/common/src/lib/types/users.ts +++ b/packages/common/src/lib/types/users.ts @@ -65,3 +65,9 @@ export interface MtfszToken { access_token: string refresh_token: string } + +export enum AgeGroup { + YOUTH = 'YOUTH', + ELITE = 'ELITE', + MASTER = 'MASTER', +} diff --git a/packages/common/src/lib/util/ranks.ts b/packages/common/src/lib/util/enumHelpers.ts similarity index 57% rename from packages/common/src/lib/util/ranks.ts rename to packages/common/src/lib/util/enumHelpers.ts index cdb03a4..01c3551 100644 --- a/packages/common/src/lib/util/ranks.ts +++ b/packages/common/src/lib/util/enumHelpers.ts @@ -1,5 +1,7 @@ import { DbEvent, Rank } from '../types/dbEvents' +import { EventRating, RatingRole } from '../types/eventRatings' import { MtfszEvent } from '../types/mtfszEvents' +import { AgeGroup } from '../types/users' export const acceptedRanks = ['REGIONALIS', 'ORSZAGOS', 'KIEMELT'] export const higherRanks = ['ORSZAGOS', 'KIEMELT'] @@ -21,3 +23,12 @@ export const getHighestRank = (e: MtfszEvent): Rank => { }) return highest } + +export const ALL_ROLES = [RatingRole.COMPETITOR, RatingRole.COACH, RatingRole.ORGANISER, RatingRole.JURY] +export const ALL_AGE_GROUPS = [AgeGroup.YOUTH, AgeGroup.ELITE, AgeGroup.MASTER] + +export const ageGroupFilterDict: { [G in AgeGroup]: (er: EventRating) => boolean } = { + YOUTH: (er) => er.raterAge < 21, + ELITE: (er) => er.raterAge > 20 && er.raterAge < 35, + MASTER: (er) => er.raterAge > 34, +} diff --git a/packages/common/src/lib/util/filters.ts b/packages/common/src/lib/util/filters.ts index 1d5fd60..051d23f 100644 --- a/packages/common/src/lib/util/filters.ts +++ b/packages/common/src/lib/util/filters.ts @@ -1,5 +1,5 @@ import { EventSection, MtfszEvent } from '../types/mtfszEvents' -import { acceptedRanks } from './ranks' +import { acceptedRanks } from './enumHelpers' export const eventFilter = (e: MtfszEvent) => e.tipus === 'VERSENY' && e.programok.some(rankedStageFilter) && (!e.datum_ig || new Date() > new Date(e.datum_ig)) diff --git a/packages/functions/package-lock.json b/packages/functions/package-lock.json index 11c7454..da7a73c 100644 --- a/packages/functions/package-lock.json +++ b/packages/functions/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.3.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "durable-functions": "^3.0.0", "env-var": "^7.3.0", "jsonwebtoken": "^9.0.0", "mssql": "^9.1.1", @@ -808,6 +809,61 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/durable-functions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.0.0.tgz", + "integrity": "sha512-VbmiHqMWcrEtgFPd4htP/ooXNP9H91FCGzNS4ZS19Wdvvc+M5cQFnLGOk7/9DfLp3cLeBXIMEgPaYjZ+Fx7k2Q==", + "dependencies": { + "@azure/functions": "^4.0.0", + "axios": "^0.21.1", + "debug": "~2.6.9", + "lodash": "^4.17.15", + "moment": "^2.29.2", + "uuid": "~3.3.2", + "validator": "~13.7.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/durable-functions/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/durable-functions/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/durable-functions/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/durable-functions/node_modules/uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/durable-functions/node_modules/validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1612,6 +1668,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3056,6 +3120,53 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" }, + "durable-functions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.0.0.tgz", + "integrity": "sha512-VbmiHqMWcrEtgFPd4htP/ooXNP9H91FCGzNS4ZS19Wdvvc+M5cQFnLGOk7/9DfLp3cLeBXIMEgPaYjZ+Fx7k2Q==", + "requires": { + "@azure/functions": "^4.0.0", + "axios": "^0.21.1", + "debug": "~2.6.9", + "lodash": "^4.17.15", + "moment": "^2.29.2", + "uuid": "~3.3.2", + "validator": "~13.7.0" + }, + "dependencies": { + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + }, + "validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + } + } + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3600,6 +3711,11 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==" }, + "moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/packages/functions/package.json b/packages/functions/package.json index 8df5e7c..f4be53e 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -18,6 +18,7 @@ "axios": "^1.3.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "durable-functions": "^3.0.0", "env-var": "^7.3.0", "jsonwebtoken": "^9.0.0", "mssql": "^9.1.1", diff --git a/packages/functions/src/functions/alerts/getAll.ts b/packages/functions/src/functions/alerts/getAll.ts new file mode 100644 index 0000000..ae01994 --- /dev/null +++ b/packages/functions/src/functions/alerts/getAll.ts @@ -0,0 +1,34 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' +import { Between } from 'typeorm' +import { getUserFromHeaderAndAssertAdmin } from '../../service/auth.service' +import Alert from '../../typeorm/entities/Alert' +import { getAppDataSource } from '../../typeorm/getConfig' +import { handleException } from '../../util/handleException' + +/** + * Called when the user visits the admin frontpage. Returns all the alerts from the past 14 days. + */ +export const getAlerts = async (req: HttpRequest, context: InvocationContext): Promise => { + try { + await getUserFromHeaderAndAssertAdmin(req, context) + const currentDate = new Date() + const pastDate = new Date() + pastDate.setDate(currentDate.getDate() - 14) + const alerts = await (await getAppDataSource(context)).getRepository(Alert).find({ + where: { + timestamp: Between(pastDate, currentDate), + }, + }) + return { + jsonBody: alerts, + } + } catch (error) { + return handleException(req, context, error) + } +} + +app.http('alerts-getAll', { + methods: ['GET'], + route: 'alerts', + handler: getAlerts, +}) diff --git a/packages/functions/src/functions/auth/login.ts b/packages/functions/src/functions/auth/login.ts index bd85ed2..fe11060 100644 --- a/packages/functions/src/functions/auth/login.ts +++ b/packages/functions/src/functions/auth/login.ts @@ -2,7 +2,6 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/fu import { PontozoException } from '@pontozo/common' import * as jwt from 'jsonwebtoken' import { DataSource } from 'typeorm' -import { getRedisClient } from '../../redis/redisClient' import { getToken, getUser } from '../../service/mtfsz.service' import UserRoleAssignment from '../../typeorm/entities/UserRoleAssignment' import { getAppDataSource } from '../../typeorm/getConfig' @@ -18,16 +17,11 @@ export const login = async (req: HttpRequest, context: InvocationContext): Promi const oauthToken = await getToken(authorizationCode) const user = await getUser(oauthToken.access_token) - const dataSource = await Promise.race([getAppDataSource(context), getRedisClient(context)]) - + const dataSource = await getAppDataSource(context) let roles = [] if (dataSource instanceof DataSource) { context.log('Logging in from DB') roles = (await dataSource.getRepository(UserRoleAssignment).find({ where: { userId: user.szemely_id } })).map((r) => r.role) - } else { - context.log('Logging in from cache') - const rawRoles = await dataSource.hGetAll(`ura:${user.szemely_id}`) - roles = rawRoles ? Object.values(rawRoles) : [] } const jwtToken = jwt.sign({ ...user, roles }, JWT_SECRET, { expiresIn: '2 days' }) diff --git a/packages/functions/src/functions/events/closeRating.ts b/packages/functions/src/functions/events/closeRating.ts deleted file mode 100644 index bc1f052..0000000 --- a/packages/functions/src/functions/events/closeRating.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { app, InvocationContext, Timer } from '@azure/functions' -import Event from '../../typeorm/entities/Event' -import { getAppDataSource } from '../../typeorm/getConfig' - -/** - * Called automatically every night to make events that have been rateable for more than ~8 days unrateable. - * Also deletes them from the cache. - */ -export const closeRating = async (myTimer: Timer, context: InvocationContext): Promise => { - try { - const pads = getAppDataSource(context) - // const predis = getRedisClient(context) - const [ads /*redisClient*/] = await Promise.all([pads /*predis*/]) - - const eventRepo = ads.getRepository(Event) - const rateableEvents = await eventRepo.find({ where: { rateable: true } }) - const now = new Date().getTime() - const toArchive = rateableEvents - .filter((event) => { - const endTimestamp = new Date(event.endDate ?? event.startDate).getTime() - return now - endTimestamp > 8 * 24 * 60 * 60 * 1000 - }) - .map((event) => ({ - ...event, - rateable: false, - })) - - await eventRepo.save(toArchive) - // const redisKeysToDelete = toArchive.map((e) => `event:${e.id}`) - // let deleted = 0 - // if (redisKeysToDelete.length > 0) { - // deleted = await redisClient.del(redisKeysToDelete) - // } - // if (toArchive.length !== deleted) { - // context.warn(`${toArchive.length} events archived, but ${deleted} events deleted from cache!`) - // } - context.log(`Closed the rating session for ${toArchive.length} event(s)`) - } catch (error) { - context.log(error) - } -} - -app.timer('events-close-rating', { - schedule: '0 0 3 * * *', // 3 AM every day - handler: closeRating, - runOnStartup: false, -}) diff --git a/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts b/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts new file mode 100644 index 0000000..84d4313 --- /dev/null +++ b/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts @@ -0,0 +1,88 @@ +import { InvocationContext } from '@azure/functions' +import { EventState, RatingStatus } from '@pontozo/common' +import * as df from 'durable-functions' +import { ActivityHandler } from 'durable-functions' +import { DataSource, IsNull } from 'typeorm' +import { getRedisClient } from '../../../redis/redisClient' +import { DBConfig } from '../../../typeorm/configOptions' +import Event from '../../../typeorm/entities/Event' +import EventRating from '../../../typeorm/entities/EventRating' +import { RatingResult } from '../../../typeorm/entities/RatingResult' +import { parseRatingResults } from '../../../util/parseRatingResults' +import { accumulateStage, StageResult } from '../../../util/ratingAverage' +import { ActivityOutput } from './closeRatingOrchestrator' + +export const calculateAvgRatingActivityName = 'calculateAvgRatingActivity' + +const calculateAvgRating: ActivityHandler = async (eventId: number, context: InvocationContext): Promise => { + try { + const ads = new DataSource(DBConfig) + await ads.initialize() + const eventRepo = ads.getRepository(Event) + + const eventRatingsPrmoise = ads.getRepository(EventRating).find({ + where: { eventId, status: RatingStatus.SUBMITTED }, + relations: { ratings: true }, + }) + const eventPromise = eventRepo.findOne({ + where: { id: eventId, state: EventState.ACCUMULATING }, + relations: { season: { categories: { category: { criteria: { criterion: true } } } }, stages: true, organisers: true }, + }) + + const [eventRatings, event] = await Promise.all([eventRatingsPrmoise, eventPromise]) + if (!event) { + context.warn(`Event:${eventId} not found or is in invalid state, cancelling accumulation.`) + return { success: false, eventId } + } + + const categories = event.season.categories.map((stc) => ({ + ...stc.category, + criteria: stc.category.criteria.map((ctc) => ctc.criterion), + })) + const categoriesWithStageCrit = categories.filter((c) => c.criteria.some((crit) => crit.stageSpecific)) + + const allEvent = accumulateStage({ + eventId, + categories, + eventRatings, + }) + let stages: StageResult[] = [] + if (event.stages.length > 1) { + stages = event.stages.map((s) => + accumulateStage({ + eventId, + stageId: s.id, + categories: categoriesWithStageCrit, + eventRatings, + }) + ) + } + await ads.manager.transaction(async (transactionalEntityManager) => { + const promises = [allEvent, ...stages].map(async (sr) => { + await transactionalEntityManager.save(sr.root) + await transactionalEntityManager.save(sr.categories) + await transactionalEntityManager.save(sr.criteria) + }) + await Promise.all(promises) + await transactionalEntityManager.getRepository(Event).update(eventId, { state: EventState.RESULTS_READY }) + event.state = EventState.RESULTS_READY + }) + context.log(`Results of event:${event.id} saved to db.`) + + const redisClient = await getRedisClient(context) + const results = await ads.getRepository(RatingResult).find({ + where: { eventId: eventId, parentId: IsNull() }, + relations: { children: { category: true, children: { criterion: true } } }, + }) + const { season, ...restOfEvent } = event + const parsed = parseRatingResults(results, restOfEvent) + await redisClient.set(`ratingResult:${event.id}`, JSON.stringify(parsed)) + context.log(`Results of event:${event.id} saved to cache.`) + + return { success: true, eventId } + } catch (e) { + context.error(`Error in calculate average rating activity for event:${eventId}: ${e}`) + return { success: false, eventId } + } +} +df.app.activity(calculateAvgRatingActivityName, { handler: calculateAvgRating }) diff --git a/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts b/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts new file mode 100644 index 0000000..8b34fb8 --- /dev/null +++ b/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts @@ -0,0 +1,69 @@ +import { AlertLevel, EventState } from '@pontozo/common' +import * as df from 'durable-functions' +import { OrchestrationContext, OrchestrationHandler } from 'durable-functions' +import { newAlertItem } from '../../../service/alert.service' +import { calculateAvgRatingActivityName } from './calculateRatingsActivity' +import { deletePreviousResultsActivityName } from './deletePreviousResultsActivity' +import { validateRatingsActivityName } from './validateRatingsActivity' + +export const orchestratorName = 'closeRatingOrchestrator' +export type ActivityOutput = { eventId: number; success: boolean } + +const orchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + const events: { eventId: number; state: EventState }[] = context.df.getInput() + context.log(`Orchestrator function started, starting the validation of ratings for ${events.length} event(s).`) + + const eventsToRedo = events.filter((e) => e.state !== EventState.VALIDATING) + if (eventsToRedo.length > 0) { + context.log(`Some event results have to be revalidated, deleting the previous results of ${eventsToRedo.length} event(s).`) + const success = yield context.df.callActivity( + deletePreviousResultsActivityName, + eventsToRedo.map((e) => e.eventId) + ) + if (!success) { + newAlertItem({ context, desc: 'Deletion of previous rating results failed!', level: AlertLevel.ERROR }) + return + } + } + + const parallelValidationTasks: df.Task[] = events + .filter((e) => e.state === EventState.VALIDATING || e.state === EventState.INVALIDATED) + .map((e) => context.df.callActivity(validateRatingsActivityName, e.eventId)) + const validationResults: ActivityOutput[] = parallelValidationTasks.length > 0 ? yield context.df.Task.all(parallelValidationTasks) : [] + + const validationSuccess = validationResults.filter((o) => o.success) + const accumulationInput = [ + ...validationSuccess.map((o) => o.eventId), + ...events.filter((e) => e.state === EventState.ACCUMULATING).map((e) => e.eventId), + ] + + if (validationSuccess.length < parallelValidationTasks.length) { + newAlertItem({ + context, + desc: `Validation of ${parallelValidationTasks.length - validationSuccess.length} event(s) failed!`, + level: AlertLevel.WARN, + }) + } + + context.log(`Validation finished, starting the average rating calculation for ${accumulationInput.length} event(s).`) + const parallelAccumulationTasks: df.Task[] = accumulationInput.map((eId) => context.df.callActivity(calculateAvgRatingActivityName, eId)) + const accumulationResults: ActivityOutput[] = yield context.df.Task.all(parallelAccumulationTasks) + + const accumulationSuccess = accumulationResults.filter((r) => r.success) + if (accumulationSuccess.length < accumulationInput.length) { + newAlertItem({ + context, + desc: `Accumulation of rating results failed for ${accumulationInput.length - accumulationSuccess.length} event(s)!`, + level: AlertLevel.WARN, + }) + } + if (accumulationSuccess.length > 0) { + newAlertItem({ + context, + desc: `Accumulation of rating results finished for ${accumulationSuccess.length} event(s)!`, + }) + } + + context.log(`Orchestrator function finished`) +} +df.app.orchestration(orchestratorName, orchestrator) diff --git a/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts b/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts new file mode 100644 index 0000000..202661b --- /dev/null +++ b/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts @@ -0,0 +1,49 @@ +import { app, InvocationContext, Timer } from '@azure/functions' +import { EventState } from '@pontozo/common' +import * as df from 'durable-functions' +import { Not } from 'typeorm' +import Event from '../../../typeorm/entities/Event' +import { getAppDataSource } from '../../../typeorm/getConfig' +import { orchestratorName } from './closeRatingOrchestrator' + +/** + * Called automatically every night to make events that have been rateable for more than ~8 days unrateable. + * Also deletes them from the cache. TODO + */ +const closeRatingStarter = async (myTimer: Timer, context: InvocationContext): Promise => { + try { + const ads = await getAppDataSource(context) + + const eventRepo = ads.getRepository(Event) + const eventsWithoutResults = await eventRepo.find({ where: { state: Not(EventState.RESULTS_READY) } }) + const now = new Date().getTime() + const toArchive = eventsWithoutResults + .filter((event) => { + const endTimestamp = new Date(event.endDate ?? event.startDate).getTime() + return event.state === EventState.RATEABLE && now - endTimestamp > 8 * 24 * 60 * 60 * 1000 + }) + .map((event) => ({ + ...event, + state: EventState.VALIDATING, + })) + + await eventRepo.save(toArchive) + context.log(`Closed the rating session for ${toArchive.length} event(s)`) + + const toClose = [...toArchive, ...eventsWithoutResults.filter((e) => e.state !== EventState.RATEABLE)] + if (toClose.length > 0) { + const client = df.getClient(context) + const instanceId = await client.startNew(orchestratorName, { input: toClose.map((e) => ({ eventId: e.id, state: e.state })) }) + context.log(`Started orchestration with ID = '${instanceId}'.`) + } + } catch (error) { + context.log(error) + } +} + +app.timer('events-close-rating', { + schedule: '1 0 0 * * *', // 1 second after midnight, every day + handler: closeRatingStarter, + runOnStartup: false, + extraInputs: [df.input.durableClient()], +}) diff --git a/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts b/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts new file mode 100644 index 0000000..6a96371 --- /dev/null +++ b/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts @@ -0,0 +1,25 @@ +import { InvocationContext } from '@azure/functions' +import * as df from 'durable-functions' +import { ActivityHandler } from 'durable-functions' +import { DataSource, In } from 'typeorm' +import { DBConfig } from '../../../typeorm/configOptions' +import { RatingResult } from '../../../typeorm/entities/RatingResult' + +export const deletePreviousResultsActivityName = 'deletePreviousResultsActivity' + +const deletePreviousResults: ActivityHandler = async (eventIds: number[], context: InvocationContext): Promise => { + try { + const ads = await new DataSource(DBConfig).initialize() + + const ratingResultRepo = ads.getRepository(RatingResult) + const ratingResults = await ratingResultRepo.find({ where: { eventId: In(eventIds) } }) + if (ratingResults.length > 0) { + await ratingResultRepo.delete(ratingResults.map((rr) => rr.id)) + } + return true + } catch (e) { + context.error(`Error in deleting previous results: ${e}`) + return false + } +} +df.app.activity(deletePreviousResultsActivityName, { handler: deletePreviousResults }) diff --git a/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts b/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts new file mode 100644 index 0000000..ed8cb31 --- /dev/null +++ b/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts @@ -0,0 +1,37 @@ +import { InvocationContext } from '@azure/functions' +import { EventState } from '@pontozo/common' +import * as df from 'durable-functions' +import { ActivityHandler } from 'durable-functions' +import { DataSource } from 'typeorm' +import { DBConfig } from '../../../typeorm/configOptions' +import Event from '../../../typeorm/entities/Event' +import { ActivityOutput } from './closeRatingOrchestrator' + +export const validateRatingsActivityName = 'validateRatingsActivity' + +const validateRatings: ActivityHandler = async (eventId: number, context: InvocationContext): Promise => { + try { + const ads = await new DataSource(DBConfig).initialize() + + const event = await ads.getRepository(Event).findOne({ + where: [ + { id: eventId, state: EventState.VALIDATING }, + { id: eventId, state: EventState.INVALIDATED }, + ], + }) + if (!event) { + context.warn(`Event:${eventId} not found or is in invalid state, cancelling validation.`) + return { success: false, eventId } + } + + // TODO validation + + event.state = EventState.ACCUMULATING + await ads.manager.save(event) + return { success: true, eventId } + } catch (e) { + context.error(`Error in validate ratings activity for event:${eventId}: ${e}`) + return { success: false, eventId } + } +} +df.app.activity(validateRatingsActivityName, { handler: validateRatings }) diff --git a/packages/functions/src/functions/events/getRateable.ts b/packages/functions/src/functions/events/getRateable.ts index 83f8d8b..23f5822 100644 --- a/packages/functions/src/functions/events/getRateable.ts +++ b/packages/functions/src/functions/events/getRateable.ts @@ -1,4 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' +import { EventState } from '@pontozo/common' import Event from '../../typeorm/entities/Event' import { getAppDataSource } from '../../typeorm/getConfig' import { handleException } from '../../util/handleException' @@ -10,7 +11,7 @@ export const getRateableEvents = async (req: HttpRequest, context: InvocationCon try { const events = await (await getAppDataSource(context)) .getRepository(Event) - .find({ where: { rateable: true }, relations: { organisers: true } }) + .find({ where: { state: EventState.RATEABLE }, relations: { organisers: true } }) return { jsonBody: events, } diff --git a/packages/functions/src/functions/events/import.ts b/packages/functions/src/functions/events/import.ts index e966fbc..93f6322 100644 --- a/packages/functions/src/functions/events/import.ts +++ b/packages/functions/src/functions/events/import.ts @@ -1,5 +1,6 @@ import { app, InvocationContext, Timer } from '@azure/functions' import { getHighestRank, getRateableEvents, stageFilter } from '@pontozo/common' +import { newAlertItem } from '../../service/alert.service' import Club from '../../typeorm/entities/Club' import Event from '../../typeorm/entities/Event' import Season from '../../typeorm/entities/Season' @@ -15,8 +16,7 @@ export const importEvents = async (myTimer: Timer, context: InvocationContext): try { const pevents = getRateableEvents(APIM_KEY, APIM_HOST) const pads = getAppDataSource(context) - // const predis = getRedisClient(context) - const [events, ads /*redisClient*/] = await Promise.all([pevents, pads /*predis*/]) + const [events, ads] = await Promise.all([pevents, pads]) const eventRepo = ads.getRepository(Event) const stageRepo = ads.getRepository(Stage) @@ -28,6 +28,7 @@ export const importEvents = async (myTimer: Timer, context: InvocationContext): context.log('No active season, skipping event import...') return } + const eventCountBefore = await eventRepo.count() const eventsToSave = events.map((e) => { const event = eventRepo.create({ id: e.esemeny_id, @@ -69,17 +70,12 @@ export const importEvents = async (myTimer: Timer, context: InvocationContext): }) await eventRepo.save(eventsToSave) - context.log(`${eventsToSave.length} events created or updated in db`) - // const [_dbres, ...redisResults] = await Promise.all([ - // eventRepo.save(eventsToSave), - // ...eventsToSave.map((event) => redisClient.set(`event:${event.id}`, JSON.stringify(event), { EX: (7 * 24 + 15) * 60 * 60 })), // expires the next Monday at 3 AM - // ]) - // context.log( - // `${eventsToSave.length} events created or updated in db, ${ - // redisResults.filter((r) => r === 'OK').length - // } events created or updated in Redis cache` - // ) + const eventCountAfter = await eventRepo.count() + const created = eventCountAfter - eventCountBefore + if (eventsToSave.length > 0) { + newAlertItem({ ads, context, desc: `${created} events created, ${eventsToSave.length - created} updated in db` }) + } } catch (error) { context.log(error) } diff --git a/packages/functions/src/functions/ratings/create.ts b/packages/functions/src/functions/ratings/create.ts index ef80d0a..71e8299 100644 --- a/packages/functions/src/functions/ratings/create.ts +++ b/packages/functions/src/functions/ratings/create.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' -import { CreateEventRating, PontozoException, RatingRole, UserRole } from '@pontozo/common' +import { CreateEventRating, EventState, PontozoException, RatingRole, UserRole } from '@pontozo/common' import { plainToClass } from 'class-transformer' import { QueryFailedError } from 'typeorm' import { getUserFromHeader } from '../../service/auth.service' @@ -29,7 +29,7 @@ export const createRating = async (req: HttpRequest, context: InvocationContext) const eventRepo = ads.getRepository(Event) const ratingRepo = ads.getRepository(EventRating) - const event = await eventRepo.findOne({ where: { id: dto.eventId, rateable: true }, relations: { stages: true } }) + const event = await eventRepo.findOne({ where: { id: dto.eventId, state: EventState.RATEABLE }, relations: { stages: true } }) if (event === null) { throw new PontozoException('A verseny nem található vagy nem értékelhető!', 404) } diff --git a/packages/functions/src/functions/ratings/rateOne.ts b/packages/functions/src/functions/ratings/rateOne.ts index f74a49a..a58b91a 100644 --- a/packages/functions/src/functions/ratings/rateOne.ts +++ b/packages/functions/src/functions/ratings/rateOne.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' -import { CreateCriterionRating, PontozoException, RatingStatus } from '@pontozo/common' +import { CreateCriterionRating, EventState, PontozoException, RatingStatus } from '@pontozo/common' import { plainToClass } from 'class-transformer' import { In, InsertResult } from 'typeorm' import { getUserFromHeader } from '../../service/auth.service' @@ -40,7 +40,7 @@ export const rateOne = async (req: HttpRequest, context: InvocationContext): Pro if (eventRating.userId !== user.szemely_id) { throw new PontozoException('Nincs jogosultságod értékelni ezt a szempontot!', 403) } - if (!eventRating.event.rateable) { + if (eventRating.event.state !== EventState.RATEABLE) { throw new PontozoException('Ez a verseny már nem értékelhető!', 400) } if (criterion === null) { diff --git a/packages/functions/src/functions/ratings/submit.ts b/packages/functions/src/functions/ratings/submit.ts index dbb0226..2944def 100644 --- a/packages/functions/src/functions/ratings/submit.ts +++ b/packages/functions/src/functions/ratings/submit.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' -import { isHigherRank, PontozoException, RatingStatus, SubmitEventRating } from '@pontozo/common' +import { EventState, isHigherRank, PontozoException, RatingStatus, SubmitEventRating } from '@pontozo/common' import { plainToClass } from 'class-transformer' import { getUserFromHeader } from '../../service/auth.service' import EventRating from '../../typeorm/entities/EventRating' @@ -32,7 +32,7 @@ export const submitOne = async (req: HttpRequest, context: InvocationContext): P if (rating.userId !== user.szemely_id) { throw new PontozoException('Te nem véglegesítheted ezt az értékelést!', 403) } - if (!rating.event.rateable) { + if (rating.event.state !== EventState.RATEABLE) { throw new PontozoException('Ezt a versenyt már nem lehet értékelni!', 400) } const { season } = rating.event diff --git a/packages/functions/src/functions/results/getOne.ts b/packages/functions/src/functions/results/getOne.ts new file mode 100644 index 0000000..46a6e6c --- /dev/null +++ b/packages/functions/src/functions/results/getOne.ts @@ -0,0 +1,51 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' +import { EventState, PontozoException } from '@pontozo/common' +import { IsNull } from 'typeorm' +import { getRedisClient } from '../../redis/redisClient' +import Event from '../../typeorm/entities/Event' +import { RatingResult } from '../../typeorm/entities/RatingResult' +import { getAppDataSource } from '../../typeorm/getConfig' +import { handleException } from '../../util/handleException' +import { parseRatingResults } from '../../util/parseRatingResults' + +export const getOneResult = async (req: HttpRequest, context: InvocationContext): Promise => { + try { + const eventId = parseInt(req.params.eventId) + if (isNaN(eventId)) { + throw new PontozoException('Érvénytelen azonosító!', 400) + } + + const redisClient = await getRedisClient(context) + const ratingResult = await redisClient.get(`ratingResult:${eventId}`) + if (ratingResult) { + return { + jsonBody: JSON.parse(ratingResult), + } + } + + const ads = await getAppDataSource(context) + const event = await ads.getRepository(Event).findOne({ where: { id: eventId }, relations: { organisers: true, stages: true } }) + if (!event) { + throw new PontozoException('A verseny nem található!', 404) + } + if (event.state !== EventState.RESULTS_READY) { + throw new PontozoException('A verseny értékelési eredményei még nem elérhetőek!', 404) + } + + const results = await ads + .getRepository(RatingResult) + .find({ where: { eventId: eventId, parentId: IsNull() }, relations: { children: { category: true, children: { criterion: true } } } }) + + return { + jsonBody: parseRatingResults(results, event), + } + } catch (error) { + return handleException(req, context, error) + } +} + +app.http('results-getOne', { + methods: ['GET'], + route: 'results/{eventId}', + handler: getOneResult, +}) diff --git a/packages/functions/src/functions/results/invalidateOne.ts b/packages/functions/src/functions/results/invalidateOne.ts new file mode 100644 index 0000000..27fc586 --- /dev/null +++ b/packages/functions/src/functions/results/invalidateOne.ts @@ -0,0 +1,37 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' +import { AlertLevel, EventState } from '@pontozo/common' +import { getRedisClient } from '../../redis/redisClient' +import { newAlertItem } from '../../service/alert.service' +import { getUserFromHeaderAndAssertAdmin } from '../../service/auth.service' +import Event from '../../typeorm/entities/Event' +import { getAppDataSource } from '../../typeorm/getConfig' +import { handleException } from '../../util/handleException' + +export const invalidateOneResult = async (req: HttpRequest, context: InvocationContext): Promise => { + try { + const user = await getUserFromHeaderAndAssertAdmin(req, context) + const eventId = parseInt(req.params.eventId) + const ads = await getAppDataSource(context) + const eventRepo = ads.getRepository(Event) + const redisClient = await getRedisClient(context) + + const event = await eventRepo.findOne({ where: { id: eventId } }) + event.state = EventState.INVALIDATED + await eventRepo.save(event) + await redisClient.del(`ratingResult:${eventId}`) + newAlertItem({ + ads, + context, + desc: `User:${user.szemely_id} invalidated the rating results of event:${eventId}`, + level: AlertLevel.WARN, + }) + } catch (error) { + return handleException(req, context, error) + } +} + +app.http('results-invalidate', { + methods: ['PATCH'], + route: 'results/{eventId}', + handler: invalidateOneResult, +}) diff --git a/packages/functions/src/functions/seasons/init.ts b/packages/functions/src/functions/seasons/init.ts index 9a1a168..4fd7950 100644 --- a/packages/functions/src/functions/seasons/init.ts +++ b/packages/functions/src/functions/seasons/init.ts @@ -1,5 +1,6 @@ import { app, InvocationContext, Timer } from '@azure/functions' import { ratingRoleArray } from '@pontozo/common' +import { newAlertItem } from '../../service/alert.service' import Season from '../../typeorm/entities/Season' import SeasonCriterionCount from '../../typeorm/entities/SeasonCriterionCount' import { getAppDataSource } from '../../typeorm/getConfig' @@ -46,7 +47,7 @@ export const initSeason = async (myTimer: Timer, context: InvocationContext): Pr return scc }) ) - context.log(`Criterion count refreshed for Season #${season.id}`) + newAlertItem({ ads, context, desc: `Criterion count refreshed for Season #${season.id}` }) } } catch (error) { context.log(error) diff --git a/packages/functions/src/service/alert.service.ts b/packages/functions/src/service/alert.service.ts new file mode 100644 index 0000000..a6deccf --- /dev/null +++ b/packages/functions/src/service/alert.service.ts @@ -0,0 +1,38 @@ +import { InvocationContext } from '@azure/functions' +import { AlertLevel } from '@pontozo/common' +import { DataSource } from 'typeorm' +import Alert from '../typeorm/entities/Alert' +import { getAppDataSource } from '../typeorm/getConfig' + +type AlertCreateInput = { + context: InvocationContext + ads?: DataSource + desc: string + level?: AlertLevel +} + +export const newAlertItem = async ({ context, desc, level = AlertLevel.INFO, ads }: AlertCreateInput) => { + try { + let dataSource = ads + if (!dataSource) { + dataSource = await getAppDataSource(context) + } + switch (level) { + case AlertLevel.INFO: + context.log(desc) + break + case AlertLevel.WARN: + context.warn(desc) + break + case AlertLevel.ERROR: + context.error(desc) + } + + const alert = new Alert() + alert.description = desc + alert.level = level + await dataSource.getRepository(Alert).save(alert) + } catch (e) { + context.error('Failed to create alert.', e) + } +} diff --git a/packages/functions/src/typeorm/configOptions.ts b/packages/functions/src/typeorm/configOptions.ts index 887d609..9e3d91b 100644 --- a/packages/functions/src/typeorm/configOptions.ts +++ b/packages/functions/src/typeorm/configOptions.ts @@ -1,5 +1,6 @@ import { SqlServerConnectionOptions } from 'typeorm/driver/sqlserver/SqlServerConnectionOptions' import { DB_NAME, DB_PWD, DB_SERVER, DB_USER, ENCRYPT, ENV } from '../util/env' +import Alert from './entities/Alert' import Category from './entities/Category' import { CategoryToCriterion } from './entities/CategoryToCriterion' import Club from './entities/Club' @@ -7,6 +8,7 @@ import Criterion from './entities/Criterion' import CriterionRating from './entities/CriterionRating' import Event from './entities/Event' import EventRating from './entities/EventRating' +import { RatingResult } from './entities/RatingResult' import Season from './entities/Season' import SeasonCriterionCount from './entities/SeasonCriterionCount' import { SeasonToCategory } from './entities/SeasonToCategory' @@ -16,6 +18,11 @@ import { Init1694205775872 } from './migrations/1694205775872-init' import { AddRaterAge1695666298049 } from './migrations/1695666298049-add_rater_age' import { UniqueEventForUser1696272553417 } from './migrations/1696272553417-unique_event_for_user' import { MessageToRating1705141629515 } from './migrations/1705141629515-message_to_rating' +import { AddRatingResult1705251815793 } from './migrations/1705251815793-add_rating_result' +import { RatingResultItemsText1705347424162 } from './migrations/1705347424162-rating_result_items_text' +import { EventState1705779906213 } from './migrations/1705779906213-event_state' +import { AddInvalidatedState1711307184824 } from './migrations/1711307184824-add_invalidated_state' +import { AddAlertTable1711394423616 } from './migrations/1711394423616-add_alert_table' export const DBConfig: SqlServerConnectionOptions = { type: 'mssql', @@ -28,6 +35,7 @@ export const DBConfig: SqlServerConnectionOptions = { logging: !(ENV === 'production'), connectionTimeout: 120000, entities: [ + Alert, Criterion, CriterionRating, EventRating, @@ -40,8 +48,19 @@ export const DBConfig: SqlServerConnectionOptions = { Stage, Club, SeasonCriterionCount, + RatingResult, ], subscribers: [], - migrations: [Init1694205775872, AddRaterAge1695666298049, UniqueEventForUser1696272553417, MessageToRating1705141629515], + migrations: [ + Init1694205775872, + AddRaterAge1695666298049, + UniqueEventForUser1696272553417, + MessageToRating1705141629515, + AddRatingResult1705251815793, + RatingResultItemsText1705347424162, + EventState1705779906213, + AddInvalidatedState1711307184824, + AddAlertTable1711394423616, + ], options: { encrypt: ENCRYPT }, } diff --git a/packages/functions/src/typeorm/entities/Alert.ts b/packages/functions/src/typeorm/entities/Alert.ts new file mode 100644 index 0000000..f1f8212 --- /dev/null +++ b/packages/functions/src/typeorm/entities/Alert.ts @@ -0,0 +1,26 @@ +import { Alert as IAlert } from '@pontozo/common' +import { Check, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm' + +enum AlertLevel { + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', +} + +@Entity() +class Alert implements Omit { + @PrimaryGeneratedColumn() + id: number + + @Column() + description: string + + @Column({ enum: AlertLevel }) + @Check("level in('INFO', 'WARN', 'ERROR')") + level: string + + @CreateDateColumn() + timestamp: Date +} + +export default Alert diff --git a/packages/functions/src/typeorm/entities/Category.ts b/packages/functions/src/typeorm/entities/Category.ts index 497b388..2e0534f 100644 --- a/packages/functions/src/typeorm/entities/Category.ts +++ b/packages/functions/src/typeorm/entities/Category.ts @@ -1,6 +1,7 @@ import { Category as ICategory } from '@pontozo/common' import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm' import { CategoryToCriterion } from './CategoryToCriterion' +import { RatingResult } from './RatingResult' import { SeasonToCategory } from './SeasonToCategory' @Entity() @@ -19,6 +20,9 @@ class Category implements ICategory { @OneToMany(() => SeasonToCategory, (ctc) => ctc.category, { cascade: true }) seasons: SeasonToCategory[] + + @OneToMany(() => RatingResult, (r) => r.criterion, { eager: false }) + ratingResults: RatingResult[] } export default Category diff --git a/packages/functions/src/typeorm/entities/Criterion.ts b/packages/functions/src/typeorm/entities/Criterion.ts index 695e02b..c8144ab 100644 --- a/packages/functions/src/typeorm/entities/Criterion.ts +++ b/packages/functions/src/typeorm/entities/Criterion.ts @@ -2,6 +2,7 @@ import { Criterion as ICriterion } from '@pontozo/common' import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm' import { CategoryToCriterion } from './CategoryToCriterion' import CriterionRating from './CriterionRating' +import { RatingResult } from './RatingResult' @Entity() class Criterion implements Omit { @@ -47,6 +48,9 @@ class Criterion implements Omit { @OneToMany(() => CriterionRating, (r) => r.criterion, { eager: false }) ratings: CriterionRating[] + @OneToMany(() => RatingResult, (r) => r.criterion, { eager: false }) + ratingResults: RatingResult[] + @Column({ type: 'nvarchar', }) diff --git a/packages/functions/src/typeorm/entities/Event.ts b/packages/functions/src/typeorm/entities/Event.ts index db4c881..ec99448 100644 --- a/packages/functions/src/typeorm/entities/Event.ts +++ b/packages/functions/src/typeorm/entities/Event.ts @@ -11,6 +11,14 @@ enum Rank { FEATURED = 'KIEMELT', } +enum EventState { + RATEABLE = 'RATEABLE', + VALIDATING = 'VALIDATING', + ACCUMULATING = 'ACCUMULATING', + RESULTS_READY = 'RESULTS_READY', + INVALIDATED = 'INVALIDATED', +} + @Entity() class Event implements DbEvent { @PrimaryColumn() @@ -34,8 +42,9 @@ class Event implements DbEvent { @OneToMany(() => EventRating, (er) => er.event, { eager: false }) ratings: EventRating[] - @Column({ default: true }) - rateable: boolean + @Column({ default: EventState.RATEABLE }) + @Check("state in('RATEABLE', 'VALIDATING', 'ACCUMULATING', 'RESULTS_READY', 'INVALIDATED')") + state: EventState @Column() @Check("highestRank in('REGIONALIS', 'ORSZAGOS', 'KIEMELT')") diff --git a/packages/functions/src/typeorm/entities/RatingResult.ts b/packages/functions/src/typeorm/entities/RatingResult.ts new file mode 100644 index 0000000..166c1ed --- /dev/null +++ b/packages/functions/src/typeorm/entities/RatingResult.ts @@ -0,0 +1,48 @@ +import { RatingResult as IRatingResult } from '@pontozo/common' +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm' +import Category from './Category' +import Criterion from './Criterion' +import Event from './Event' +import Stage from './Stage' + +@Entity() +export class RatingResult implements Omit { + @PrimaryGeneratedColumn() + id: number + + @Column({ nullable: true }) + parentId?: number + + @ManyToOne(() => RatingResult, (c) => c.children, { onDelete: 'NO ACTION', nullable: true }) + parent?: RatingResult + + @OneToMany(() => RatingResult, (r) => r.parent, { eager: false, onDelete: 'NO ACTION' }) + children: RatingResult[] + + @Column() + eventId: number + + @ManyToOne(() => Event, (e) => e.ratings, { onDelete: 'CASCADE', nullable: false }) + event: Event + + @Column({ nullable: true }) + stageId?: number + + @ManyToOne(() => Stage, (s) => s.ratings, { onDelete: 'NO ACTION', nullable: true }) + stage?: Stage + + @Column({ nullable: true }) + criterionId?: number + + @ManyToOne(() => Criterion, (c) => c.ratingResults, { onDelete: 'CASCADE', nullable: true }) + criterion?: Criterion + + @Column({ nullable: true }) + categoryId?: number + + @ManyToOne(() => Category, (c) => c.ratingResults, { onDelete: 'CASCADE', nullable: true }) + category?: Category + + @Column({ type: 'text' }) + items: string +} diff --git a/packages/functions/src/typeorm/migrations/1705141629515-message_to_rating.ts b/packages/functions/src/typeorm/migrations/1705141629515-message_to_rating.ts index ea567bd..28a1529 100644 --- a/packages/functions/src/typeorm/migrations/1705141629515-message_to_rating.ts +++ b/packages/functions/src/typeorm/migrations/1705141629515-message_to_rating.ts @@ -1,14 +1,13 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm' export class MessageToRating1705141629515 implements MigrationInterface { - name = 'MessageToRating1705141629515' + name = 'MessageToRating1705141629515' - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "event_rating" ADD "message" nvarchar(255)`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "event_rating" DROP COLUMN "message"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event_rating" ADD "message" nvarchar(255)`) + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event_rating" DROP COLUMN "message"`) + } } diff --git a/packages/functions/src/typeorm/migrations/1705251815793-add_rating_result.ts b/packages/functions/src/typeorm/migrations/1705251815793-add_rating_result.ts new file mode 100644 index 0000000..4b74698 --- /dev/null +++ b/packages/functions/src/typeorm/migrations/1705251815793-add_rating_result.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddRatingResult1705251815793 implements MigrationInterface { + name = 'AddRatingResult1705251815793' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "rating_result" ("id" int NOT NULL IDENTITY(1,1), "parentId" int, "eventId" int NOT NULL, "stageId" int, "criterionId" int, "categoryId" int, "items" nvarchar(255) NOT NULL, CONSTRAINT "PK_cdb2f177c64795e2c911de17df7" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `ALTER TABLE "rating_result" ADD CONSTRAINT "FK_eacb4b10b2b33ef0648a264cd21" FOREIGN KEY ("parentId") REFERENCES "rating_result"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "rating_result" ADD CONSTRAINT "FK_e1b3811b7603ca79f37c476dfc6" FOREIGN KEY ("eventId") REFERENCES "event"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "rating_result" ADD CONSTRAINT "FK_637a0e96aa44f923e403b90bf4d" FOREIGN KEY ("stageId") REFERENCES "stage"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "rating_result" ADD CONSTRAINT "FK_b8e92010b73ef6b5d531066ddb9" FOREIGN KEY ("criterionId") REFERENCES "criterion"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "rating_result" ADD CONSTRAINT "FK_aac143e39399d12911644288a36" FOREIGN KEY ("categoryId") REFERENCES "category"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "rating_result" DROP CONSTRAINT "FK_aac143e39399d12911644288a36"`) + await queryRunner.query(`ALTER TABLE "rating_result" DROP CONSTRAINT "FK_b8e92010b73ef6b5d531066ddb9"`) + await queryRunner.query(`ALTER TABLE "rating_result" DROP CONSTRAINT "FK_637a0e96aa44f923e403b90bf4d"`) + await queryRunner.query(`ALTER TABLE "rating_result" DROP CONSTRAINT "FK_e1b3811b7603ca79f37c476dfc6"`) + await queryRunner.query(`ALTER TABLE "rating_result" DROP CONSTRAINT "FK_eacb4b10b2b33ef0648a264cd21"`) + await queryRunner.query(`DROP TABLE "rating_result"`) + } +} diff --git a/packages/functions/src/typeorm/migrations/1705347424162-rating_result_items_text.ts b/packages/functions/src/typeorm/migrations/1705347424162-rating_result_items_text.ts new file mode 100644 index 0000000..bbdbf53 --- /dev/null +++ b/packages/functions/src/typeorm/migrations/1705347424162-rating_result_items_text.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class RatingResultItemsText1705347424162 implements MigrationInterface { + name = 'RatingResultItemsText1705347424162' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "rating_result" DROP COLUMN "items"`) + await queryRunner.query(`ALTER TABLE "rating_result" ADD "items" text NOT NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "rating_result" DROP COLUMN "items"`) + await queryRunner.query(`ALTER TABLE "rating_result" ADD "items" nvarchar(255) NOT NULL`) + } +} diff --git a/packages/functions/src/typeorm/migrations/1705779906213-event_state.ts b/packages/functions/src/typeorm/migrations/1705779906213-event_state.ts new file mode 100644 index 0000000..4a394ab --- /dev/null +++ b/packages/functions/src/typeorm/migrations/1705779906213-event_state.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class EventState1705779906213 implements MigrationInterface { + name = 'EventState1705779906213' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`EXEC sp_rename "pontozo-dtu-db.dbo.event.rateable", "state"`) + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "DF_60718ea2c6e6abd42b982dd321a"`) + await queryRunner.query(`ALTER TABLE "event" ADD CONSTRAINT "DF_5bcb4691305a70a213f794665c2" DEFAULT 1 FOR "state"`) + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "DF_5bcb4691305a70a213f794665c2"`) + await queryRunner.query(`ALTER TABLE "event" DROP COLUMN "state"`) + await queryRunner.query( + `ALTER TABLE "event" ADD "state" nvarchar(255) NOT NULL CONSTRAINT "DF_5bcb4691305a70a213f794665c2" DEFAULT 'RATEABLE'` + ) + await queryRunner.query( + `ALTER TABLE "event" ADD CONSTRAINT "CHK_0ab24dbc30d489f8fde0c108f4" CHECK (state in('RATEABLE', 'VALIDATING', 'ACCUMULATING', 'RESULTS_READY'))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "CHK_0ab24dbc30d489f8fde0c108f4"`) + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "DF_5bcb4691305a70a213f794665c2"`) + await queryRunner.query(`ALTER TABLE "event" DROP COLUMN "state"`) + await queryRunner.query(`ALTER TABLE "event" ADD "state" bit NOT NULL`) + await queryRunner.query(`ALTER TABLE "event" ADD CONSTRAINT "DF_5bcb4691305a70a213f794665c2" DEFAULT 1 FOR "state"`) + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "DF_5bcb4691305a70a213f794665c2"`) + await queryRunner.query(`ALTER TABLE "event" ADD CONSTRAINT "DF_60718ea2c6e6abd42b982dd321a" DEFAULT 1 FOR "state"`) + await queryRunner.query(`EXEC sp_rename "pontozo-dtu-db.dbo.event.state", "rateable"`) + } +} diff --git a/packages/functions/src/typeorm/migrations/1711307184824-add_invalidated_state.ts b/packages/functions/src/typeorm/migrations/1711307184824-add_invalidated_state.ts new file mode 100644 index 0000000..3e362c3 --- /dev/null +++ b/packages/functions/src/typeorm/migrations/1711307184824-add_invalidated_state.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddInvalidatedState1711307184824 implements MigrationInterface { + name = 'AddInvalidatedState1711307184824' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "CHK_0ab24dbc30d489f8fde0c108f4"`) + await queryRunner.query( + `ALTER TABLE "event" ADD CONSTRAINT "CHK_c4242a2bba095d32a38c436e82" CHECK (state in('RATEABLE', 'VALIDATING', 'ACCUMULATING', 'RESULTS_READY', 'INVALIDATED'))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "CHK_c4242a2bba095d32a38c436e82"`) + await queryRunner.query( + `ALTER TABLE "event" ADD CONSTRAINT "CHK_0ab24dbc30d489f8fde0c108f4" CHECK (([state]='RESULTS_READY' OR [state]='ACCUMULATING' OR [state]='VALIDATING' OR [state]='RATEABLE'))` + ) + } +} diff --git a/packages/functions/src/typeorm/migrations/1711394423616-add_alert_table.ts b/packages/functions/src/typeorm/migrations/1711394423616-add_alert_table.ts new file mode 100644 index 0000000..971bb62 --- /dev/null +++ b/packages/functions/src/typeorm/migrations/1711394423616-add_alert_table.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddAlertTable1711394423616 implements MigrationInterface { + name = 'AddAlertTable1711394423616' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "alert" ("id" int NOT NULL IDENTITY(1,1), "description" nvarchar(255) NOT NULL, "level" nvarchar(255) CONSTRAINT CHK_ee75bc5fba8375db39561dfc7b_ENUM CHECK(level IN ('INFO','WARN','ERROR')) NOT NULL, "timestamp" datetime2 NOT NULL CONSTRAINT "DF_1425c319fed1b6466b5084254f9" DEFAULT getdate(), CONSTRAINT "CHK_955146385b697896a85a873a29" CHECK (level in('INFO', 'WARN', 'ERROR')), CONSTRAINT "PK_ad91cad659a3536465d564a4b2f" PRIMARY KEY ("id"))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "alert"`) + } +} diff --git a/packages/functions/src/util/parseRatingResults.ts b/packages/functions/src/util/parseRatingResults.ts new file mode 100644 index 0000000..bdefbda --- /dev/null +++ b/packages/functions/src/util/parseRatingResults.ts @@ -0,0 +1,30 @@ +import { Criterion as Criterion_DTO, DbEvent, EventWithResults, RatingResult as RR_DTO } from '@pontozo/common' +import Criterion_DB from '../typeorm/entities/Criterion' +import { RatingResult as RR_DB } from '../typeorm/entities/RatingResult' + +export const parseRatingResults = (results: RR_DB[], event: DbEvent): EventWithResults => { + const parsed: RR_DTO[] = results.map((r) => ({ + ...r, + items: JSON.parse(r.items), + criterion: parseCriterion(r.criterion), + children: r.children.map((c) => ({ + ...c, + items: JSON.parse(c.items), + criterion: parseCriterion(c.criterion), + children: c.children.map((cc) => ({ ...cc, items: JSON.parse(cc.items), criterion: parseCriterion(cc.criterion) })), + })), + })) + return { + ...event, + ratingResults: parsed.find((r) => !r.stageId), + stages: event.stages.map((s) => ({ ...s, ratingResults: parsed.find((r) => r.stageId === s.id) })), + } +} + +const parseCriterion = (c: Criterion_DB | undefined): Criterion_DTO => { + if (!c) return undefined + return { + ...c, + roles: JSON.parse(c.roles), + } +} diff --git a/packages/functions/src/util/ratingAverage.ts b/packages/functions/src/util/ratingAverage.ts new file mode 100644 index 0000000..6fcc579 --- /dev/null +++ b/packages/functions/src/util/ratingAverage.ts @@ -0,0 +1,128 @@ +import { ageGroupFilterDict, ALL_AGE_GROUPS, ALL_ROLES, RatingResult as IRatingResult, RatingResultItem } from '@pontozo/common' +import Category from '../typeorm/entities/Category' +import Criterion from '../typeorm/entities/Criterion' +import CriterionRating from '../typeorm/entities/CriterionRating' +import EventRating from '../typeorm/entities/EventRating' +import { RatingResult } from '../typeorm/entities/RatingResult' +type Average = { + count: number + average: number +} + +export const ratingAverage = ( + ratings: EventRating[], + erFilter: (er: EventRating) => boolean = () => true, + crFilter: (cr: CriterionRating) => boolean = () => true +): Average => { + const res: Average = { + count: 0, + average: 0, + } + let sum = 0 + for (const er of ratings) { + if (erFilter(er)) { + for (const r of er.ratings) { + if (r.value > -1 && crFilter(r)) { + res.count++ + sum += r.value + } + } + } + } + if (res.count === 0) { + res.average = -1 + } else { + res.average = sum / res.count + } + return res +} + +export const averageByRoleAndGroup = ( + eventRatings: EventRating[], + crFilter: (cr: CriterionRating) => boolean = () => true +): RatingResultItem[] => [ + ratingAverage(eventRatings, () => true, crFilter), + ...ALL_ROLES.map((role) => { + const result: RatingResultItem = ratingAverage(eventRatings, (er) => er.role === role, crFilter) + result.role = role + return result + }), + ...ALL_AGE_GROUPS.map((ageGroup) => { + const result: RatingResultItem = ratingAverage(eventRatings, ageGroupFilterDict[ageGroup], crFilter) + result.ageGroup = ageGroup + return result + }), +] + +export const accumulateCategory = (results: Omit[]): RatingResultItem[] => { + const variatons = ALL_ROLES.length + ALL_AGE_GROUPS.length + 1 + const zeroToN = Array.from({ length: variatons }, (_, i) => i) + const sum = Array.from({ length: variatons }, () => 0) + const count = Array.from({ length: variatons }, () => 0) + + results.forEach((r) => { + zeroToN.forEach((i) => { + sum[i] += r.items[i].average * r.items[i].count + count[i] += r.items[i].count + }) + }) + return zeroToN.map((i) => ({ + count: count[i], + average: count[i] === 0 ? -1 : sum[i] / count[i], + ageGroup: results[0].items[i].ageGroup, + role: results[0].items[i].role, + })) +} + +type Params = { + eventId: number + stageId?: number + categories: (Omit & { criteria: Criterion[] })[] + eventRatings: EventRating[] +} + +export type StageResult = { + root: RatingResult + categories: RatingResult[] + criteria: RatingResult[] +} + +export const accumulateStage = ({ eventId, stageId, categories, eventRatings }: Params): StageResult => { + const root = new RatingResult() + root.eventId = eventId + root.stageId = stageId + + const categoryResultEntitesAndRawData = categories.map((c) => { + const categoryResult = new RatingResult() + categoryResult.eventId = eventId + categoryResult.parent = root + categoryResult.categoryId = c.id + categoryResult.stageId = stageId + + const criteriaResults = c.criteria + .filter((c) => !stageId || c.stageSpecific) + .map((crit) => ({ + eventId, + items: averageByRoleAndGroup(eventRatings, (cr) => cr.criterionId === crit.id && (!stageId || cr.stageId === stageId)), + criterionId: crit.id, + })) + const rawCategory = accumulateCategory(criteriaResults) + categoryResult.items = JSON.stringify(rawCategory) + const criteriaResultEntities: RatingResult[] = criteriaResults.map((cr) => { + const cre = new RatingResult() + cre.eventId = cr.eventId + cre.criterionId = cr.criterionId + cre.parent = categoryResult + cre.items = JSON.stringify(cr.items) + cre.stageId = stageId + return cre + }) + categoryResult.children = criteriaResultEntities + return { entity: categoryResult, raw: rawCategory } + }) + root.children = categoryResultEntitesAndRawData.map((cr) => cr.entity) + root.items = JSON.stringify( + accumulateCategory(categoryResultEntitesAndRawData.map((cr) => ({ eventId, items: cr.raw, categoryId: cr.entity.categoryId }))) + ) + return { root, categories: root.children, criteria: root.children.flatMap((s) => s.children) } +}