From e2fcedea5d0e87cc161d4347e722c1f39d2aa39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1muel=20Fekete?= Date: Wed, 1 Jan 2025 21:22:53 +0100 Subject: [PATCH] recalculate function, client modal, docs improvements --- packages/client/src/api/hooks/seasonHooks.ts | 8 +- .../pages/seasons/WeightAdjustment.page.tsx | 10 +-- .../seasons/components/RecalculateModal.tsx | 76 +++++++++++++++++++ .../components/SourceSeasonSelectorModal.tsx | 4 +- .../pages/seasons/components/WeightInput.tsx | 4 +- packages/client/src/util/enumHelpers.ts | 2 +- .../closeRating/calculateRatingsActivity.ts | 5 ++ .../closeRating/closeRatingOrchestrator.ts | 4 + .../events/closeRating/closeRatingStarter.ts | 2 +- .../deletePreviousResultsActivity.ts | 20 ++++- .../closeRating/validateRatingsActivity.ts | 5 ++ .../src/functions/results/invalidateOne.ts | 8 +- .../src/functions/seasons/recalculate.ts | 64 ++++++++++++++++ 13 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 packages/client/src/pages/seasons/components/RecalculateModal.tsx create mode 100644 packages/functions/src/functions/seasons/recalculate.ts diff --git a/packages/client/src/api/hooks/seasonHooks.ts b/packages/client/src/api/hooks/seasonHooks.ts index 45ed6ef..4aa3715 100644 --- a/packages/client/src/api/hooks/seasonHooks.ts +++ b/packages/client/src/api/hooks/seasonHooks.ts @@ -39,19 +39,23 @@ export const useFetchSeasonWeights = (seasonId?: string) => { ) } -export const useSetWeightsMutations = (seasonId: string, criterionId: number) => { +export const useSetWeightsMutation = (seasonId: string, criterionId: number) => { return useMutation( async (formdata) => await functionAxios.put(`/seasons/${seasonId}/weights/${criterionId}`, formdata), { onSuccess: () => queryClient.invalidateQueries(['fetchSeasonWeights', seasonId]) } ) } -export const useCopyWeightsMutations = (destSeasonId: string) => { +export const useCopyWeightsMutation = (destSeasonId: string) => { return useMutation( async (sourceSeasonId) => await functionAxios.put(`/seasons/${destSeasonId}/copyWeights/${sourceSeasonId}`) ) } +export const useRecalculateSeasonMutation = (seasonId: string) => { + return useMutation(async () => await functionAxios.post(`/seasons/${seasonId}/recalculate/`)) +} + export const useCreateSeasonMutation = () => { return useMutation( async (formData) => (await functionAxios.post(`/seasons`, formData)).data diff --git a/packages/client/src/pages/seasons/WeightAdjustment.page.tsx b/packages/client/src/pages/seasons/WeightAdjustment.page.tsx index b8f138d..18f2803 100644 --- a/packages/client/src/pages/seasons/WeightAdjustment.page.tsx +++ b/packages/client/src/pages/seasons/WeightAdjustment.page.tsx @@ -1,6 +1,6 @@ import { Alert, AlertDescription, AlertIcon, Button, Heading, HStack, SimpleGrid, Stack, Text, Tooltip, VStack } from '@chakra-ui/react' import { useMemo } from 'react' -import { FaEdit, FaMedal, FaRedoAlt } from 'react-icons/fa' +import { FaEdit, FaMedal } from 'react-icons/fa' import { useNavigate, useParams } from 'react-router-dom' import { useAuthContext } from 'src/api/contexts/useAuthContext' import { useFetchSeasonWeights } from 'src/api/hooks/seasonHooks' @@ -10,6 +10,7 @@ import { NavigateWithError } from 'src/components/commons/NavigateWithError' import { criterionWeightReducer } from 'src/util/criterionWeightHelper' import { PATHS } from 'src/util/paths' import { CategoryWeights } from './components/CategoryWeights' +import { RecalculateModal } from './components/RecalculateModal' import { SourceSeasonSelectorModal } from './components/SourceSeasonSelectorModal' export const WeightAdjustmentPage = () => { @@ -86,7 +87,8 @@ export const WeightAdjustmentPage = () => { Az oldalon egy érték átírása azonnal átírja az értéket az adatbázisban, nem kell semmilyen mentés gombra kattintani. Azonban az - értékelések eredményei nem számolódnak újra minden súlyérték változásakor, csakis ha azt manuálisan elindítod (coming soon). + értékelések eredményei nem számolódnak újra minden súlyérték változásakor, csakis ha azt manuálisan elindítod a lenti piros + gombbal. @@ -95,9 +97,7 @@ export const WeightAdjustmentPage = () => { - + diff --git a/packages/client/src/pages/seasons/components/RecalculateModal.tsx b/packages/client/src/pages/seasons/components/RecalculateModal.tsx new file mode 100644 index 0000000..7069e40 --- /dev/null +++ b/packages/client/src/pages/seasons/components/RecalculateModal.tsx @@ -0,0 +1,76 @@ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useDisclosure, + useToast, +} from '@chakra-ui/react' +import { FaRedoAlt } from 'react-icons/fa' +import { useRecalculateSeasonMutation } from 'src/api/hooks/seasonHooks' + +export const RecalculateModal = ({ seasonId }: { seasonId: string }) => { + const { isOpen, onOpen, onClose } = useDisclosure() + const { mutate } = useRecalculateSeasonMutation(seasonId) + const toast = useToast() + + const onSubmit = () => { + mutate(undefined, { + onSuccess: () => { + onClose() + toast({ + title: 'Az újraszámítás megkezdődött!', + description: 'Kérlek légy türelemmel, hamarosan már a frissített eredmények fogod látni a versenyek publikus oldalán!', + status: 'success', + }) + }, + }) + } + + return ( + <> + + + + + + Pontok újraszámítása + + + + + A piros gomb megnyomásával elindítod ezen szezon lezajlott versenyeinek értékelési eredményeinek újraszámítását a jelenlegi + súlyértékekkel. Ez egy hosszabb és erőforrásigényesebb művelet is lehet{' '} + + (a lezajlott versenyek számától függően), ezért lehetőleg ne használd túl gyakran. A folyamat elindítását követő pár percben + lehet, hogy lassabban fog működni az oldal. Amennyiben a teljesítmény hosszabb idő után sem javul, vagy furcsa anomáliák + történnek, kérlek jelezd a fejlesztőnek. Az admin felület Tudnivalók oldal alján láthatsz frissítéseket a folyamat + haladásáról. + + {' '} + Azt is tarstd fejben, hogy az újraszámolástól a már publikált eredmények megváltozhatnak, tehát szezon közben jobb lenne ezt + elkerülni. + + + + + + + + + + + + ) +} diff --git a/packages/client/src/pages/seasons/components/SourceSeasonSelectorModal.tsx b/packages/client/src/pages/seasons/components/SourceSeasonSelectorModal.tsx index 794596c..b59da94 100644 --- a/packages/client/src/pages/seasons/components/SourceSeasonSelectorModal.tsx +++ b/packages/client/src/pages/seasons/components/SourceSeasonSelectorModal.tsx @@ -14,14 +14,14 @@ import { } from '@chakra-ui/react' import { ChangeEvent, useMemo, useState } from 'react' import { FaFileImport } from 'react-icons/fa' -import { useCopyWeightsMutations, useFetchSeasons } from 'src/api/hooks/seasonHooks' +import { useCopyWeightsMutation, useFetchSeasons } from 'src/api/hooks/seasonHooks' import { LoadingSpinner } from 'src/components/commons/LoadingSpinner' import { queryClient } from 'src/util/queryClient' export const SourceSeasonSelectorModal = ({ currentSeasonId }: { currentSeasonId: string }) => { const { isOpen, onOpen, onClose } = useDisclosure() const { isLoading, data } = useFetchSeasons() - const { mutate } = useCopyWeightsMutations(currentSeasonId) + const { mutate } = useCopyWeightsMutation(currentSeasonId) const [selectedSeasonId, setSelectedSeasonId] = useState() const toast = useToast() diff --git a/packages/client/src/pages/seasons/components/WeightInput.tsx b/packages/client/src/pages/seasons/components/WeightInput.tsx index d9d34ae..62bab90 100644 --- a/packages/client/src/pages/seasons/components/WeightInput.tsx +++ b/packages/client/src/pages/seasons/components/WeightInput.tsx @@ -2,7 +2,7 @@ import { Box, NumberDecrementStepper, NumberIncrementStepper, NumberInput, Numbe import { Criterion, CriterionWeightKey, RatingRole } from '@pontozo/common' import debounce from 'lodash.debounce' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useSetWeightsMutations } from 'src/api/hooks/seasonHooks' +import { useSetWeightsMutation } from 'src/api/hooks/seasonHooks' type Props = { roles: RatingRole[] @@ -13,7 +13,7 @@ type Props = { } export const WeightInput = ({ roles, criterion, seasonId, defaultValue, weightKey }: Props) => { - const { mutate } = useSetWeightsMutations(seasonId, criterion.id) + const { mutate } = useSetWeightsMutation(seasonId, criterion.id) const weightEditable = useMemo(() => { const asSet = new Set(criterion.roles) return roles.some((r) => asSet.has(r)) diff --git a/packages/client/src/util/enumHelpers.ts b/packages/client/src/util/enumHelpers.ts index 074710e..eef8fe3 100644 --- a/packages/client/src/util/enumHelpers.ts +++ b/packages/client/src/util/enumHelpers.ts @@ -131,5 +131,5 @@ export const eventStateColor: EventStateDict = { [EventState.VALIDATING]: 'gray', [EventState.ACCUMULATING]: 'gray', [EventState.RESULTS_READY]: 'orange', - [EventState.INVALIDATED]: 'yellow', + [EventState.INVALIDATED]: 'mtfszRed', } diff --git a/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts b/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts index 84d4313..2424a02 100644 --- a/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts +++ b/packages/functions/src/functions/events/closeRating/calculateRatingsActivity.ts @@ -14,6 +14,11 @@ import { ActivityOutput } from './closeRatingOrchestrator' export const calculateAvgRatingActivityName = 'calculateAvgRatingActivity' +/** + * Durable Functions activity that calculates all the avarages of the ratings for a given event. It stores the result in the DB and in Redis. + * @param eventId ID of the event to calculate the results for + * @returns whether the operation succeeded and the eventId + */ const calculateAvgRating: ActivityHandler = async (eventId: number, context: InvocationContext): Promise => { try { const ads = new DataSource(DBConfig) diff --git a/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts b/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts index 8b34fb8..4cf6ffe 100644 --- a/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts +++ b/packages/functions/src/functions/events/closeRating/closeRatingOrchestrator.ts @@ -9,6 +9,10 @@ import { validateRatingsActivityName } from './validateRatingsActivity' export const orchestratorName = 'closeRatingOrchestrator' export type ActivityOutput = { eventId: number; success: boolean } +/** + * Durable Functions orchestrator function that executes the activies that validate and accumulate the ratings of finished events. + * TODO better docs, maybe diagrams? + */ 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).`) diff --git a/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts b/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts index 7ab36a3..4d649da 100644 --- a/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts +++ b/packages/functions/src/functions/events/closeRating/closeRatingStarter.ts @@ -9,7 +9,7 @@ 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 + * Then it starts the closeRating orchestration that will eventually validate and accumulate the ratings. */ const closeRatingStarter = async (myTimer: Timer, context: InvocationContext): Promise => { try { diff --git a/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts b/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts index 6a96371..0bb6a90 100644 --- a/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts +++ b/packages/functions/src/functions/events/closeRating/deletePreviousResultsActivity.ts @@ -2,20 +2,38 @@ import { InvocationContext } from '@azure/functions' import * as df from 'durable-functions' import { ActivityHandler } from 'durable-functions' import { DataSource, In } from 'typeorm' +import { getRedisClient } from '../../../redis/redisClient' import { DBConfig } from '../../../typeorm/configOptions' import { RatingResult } from '../../../typeorm/entities/RatingResult' export const deletePreviousResultsActivityName = 'deletePreviousResultsActivity' +/** + * Durable Functions activity that deletes all the previous rating results for event whose ratings have been invalidated. + * It deletes the entries by events to reduce the load on the DB. + * @param eventIds list of eventIDs, whose rating result records will be deleted + * @returns whether the operation succeeded + */ const deletePreviousResults: ActivityHandler = async (eventIds: number[], context: InvocationContext): Promise => { try { const ads = await new DataSource(DBConfig).initialize() + const redisClient = await getRedisClient(context) 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)) + const resultsGroupedByEventId = ratingResults.reduce((map, rr) => { + if (!map[rr.eventId]) { + map[rr.eventId] = [] + } + map[rr.eventId].push(rr) + return map + }, {} as Record) + for (const eventId of Object.keys(resultsGroupedByEventId)) { + await ratingResultRepo.delete(resultsGroupedByEventId[parseInt(eventId)].map((rr) => rr.id)) + } } + await redisClient.del(eventIds.map((eId) => `ratingResult:${eId}`)) return true } catch (e) { context.error(`Error in deleting previous results: ${e}`) diff --git a/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts b/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts index ed8cb31..a5aa289 100644 --- a/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts +++ b/packages/functions/src/functions/events/closeRating/validateRatingsActivity.ts @@ -9,6 +9,11 @@ import { ActivityOutput } from './closeRatingOrchestrator' export const validateRatingsActivityName = 'validateRatingsActivity' +/** + * Durable Functions activity that validates all the ratings for a given event. For now it does nothing, just a placeholder TODO + * @param eventId ID of the event whose ratings wil be validated + * @returns whether the operation succeeded and the eventId + */ const validateRatings: ActivityHandler = async (eventId: number, context: InvocationContext): Promise => { try { const ads = await new DataSource(DBConfig).initialize() diff --git a/packages/functions/src/functions/results/invalidateOne.ts b/packages/functions/src/functions/results/invalidateOne.ts index 2d966c2..2d5c887 100644 --- a/packages/functions/src/functions/results/invalidateOne.ts +++ b/packages/functions/src/functions/results/invalidateOne.ts @@ -1,24 +1,26 @@ 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' +/** + * A util function to invalidate the rating results of an event. It sets its state to 'INVALIDATED', + * so the next time the closeRating orchestrator will be run, the results will be recalculated for this event as well. + * It can't be called from the client, only manualy with an admin JWT. + */ 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}`) await newAlertItem({ ads, context, diff --git a/packages/functions/src/functions/seasons/recalculate.ts b/packages/functions/src/functions/seasons/recalculate.ts new file mode 100644 index 0000000..5cc2399 --- /dev/null +++ b/packages/functions/src/functions/seasons/recalculate.ts @@ -0,0 +1,64 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions' +import { AlertLevel, EventState, PontozoException } from '@pontozo/common' +import * as df from 'durable-functions' +import { newAlertItem } from '../../service/alert.service' +import { getUserFromHeaderAndAssertAdmin } from '../../service/auth.service' +import Event from '../../typeorm/entities/Event' +import Season from '../../typeorm/entities/Season' +import { getAppDataSource } from '../../typeorm/getConfig' +import { handleException } from '../../util/handleException' +import { validateId } from '../../util/validation' +import { orchestratorName } from '../events/closeRating/closeRatingOrchestrator' + +/** + * Function to initiate the recalculation of rating scores for every event in a season. + * It is called when an admin clicks on the 'Recalculate scores' button on the season's weight adjustment page. + * The state of all the events of the season that are no longer rateable will be set to 'INVALITED', then + * the closeRating orchestration function is called, which will redo the validation and accumulation stages for these events. + */ +const recalculateRatingsInSeason = async (req: HttpRequest, context: InvocationContext): Promise => { + try { + const user = await getUserFromHeaderAndAssertAdmin(req, context) + const seasonId = validateId(req) + const ads = await getAppDataSource(context) + + const season = await ads.getRepository(Season).findOne({ where: { id: seasonId }, relations: { events: true } }) + if (!season) { + throw new PontozoException('A szezon nem talalhato!', 404) + } + + await newAlertItem({ + context, + desc: `User #${user.szemely_id} initiated result recalculation for Season #${seasonId}`, + level: AlertLevel.INFO, + ads, + }) + + const invalidatedEvents = season.events + .filter((e) => e.state !== EventState.RATEABLE) + .map((e) => ({ ...e, state: EventState.INVALIDATED })) + + if (invalidatedEvents.length > 0) { + await ads.getRepository(Event).save(invalidatedEvents) + const client = df.getClient(context) + const instanceId = await client.startNew(orchestratorName, { + input: invalidatedEvents.map((e) => ({ eventId: e.id, state: e.state })), + }) + context.log(`Started orchestration with ID = '${instanceId}'.`) + } else { + context.log(`No events to recalculate`) + } + return { + status: 204, + } + } catch (error) { + return handleException(req, context, error) + } +} + +app.http('season-recalculate', { + route: 'seasons/{id}/recalculate', + methods: ['POST'], + handler: recalculateRatingsInSeason, + extraInputs: [df.input.durableClient()], +})