From 1d384a36796052ae3fae9fe0d447911e0dcf1660 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 19 Dec 2023 13:23:22 +0100 Subject: [PATCH] fix: change requests --- common/achievement/create.ts | 20 ++++++-- common/achievement/evaluate.ts | 12 ++--- common/achievement/get.ts | 3 +- common/achievement/index.ts | 81 ++++++++++++++++++++------------ common/achievement/metrics.ts | 11 ++--- common/achievement/template.ts | 40 ++++------------ common/achievement/types.ts | 11 +---- common/achievement/util.ts | 22 ++------- common/notification/actions.ts | 14 ------ common/notification/index.ts | 1 + graphql/achievement/mutations.ts | 12 ----- graphql/ownership.ts | 1 + graphql/user/mutations.ts | 9 ++-- utils/environment.ts | 4 ++ 14 files changed, 107 insertions(+), 134 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 4234e79a3..ac2cf8325 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -4,8 +4,13 @@ import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; import tracer from '../logger/tracing'; +import { AchievementToCheck } from './types'; -async function findUserAchievement(templateId: number, userId: string, context: SpecificNotificationContext) { +async function findUserAchievement( + templateId: number, + userId: string, + context: SpecificNotificationContext +): Promise { const keys = context ? Object.keys(context) : []; const userAchievement = await prisma.user_achievement.findFirst({ where: { @@ -25,12 +30,16 @@ async function findUserAchievement(templateId: number, user return userAchievement; } -async function getOrCreateUserAchievement(template: Achievement_template, userId: string, context?: SpecificNotificationContext) { +async function getOrCreateUserAchievement( + template: Achievement_template, + userId: string, + context?: SpecificNotificationContext +): Promise { const isGlobal = template.templateFor === achievement_template_for_enum.Global || template.templateFor === achievement_template_for_enum.Global_Courses || template.templateFor === achievement_template_for_enum.Global_Matches; - const existingUserAchievement = await findUserAchievement(template.id, userId, !isGlobal && context); + const existingUserAchievement: AchievementToCheck = await findUserAchievement(template.id, userId, !isGlobal ? context : undefined); if (!existingUserAchievement) { return await createAchievement(template, userId, context); } @@ -74,10 +83,13 @@ async function createNextUserAchievement( userId: string, context: SpecificNotificationContext ) { + if (templatesForGroup.length <= nextStepIndex) { + return; + } const nextStepTemplate = templatesForGroup[nextStepIndex]; // Here a user template is created for the next template in the group. This is done to always have the data availible for the next step. // This could mean to, for example, have the name of a match partner that is not yet availible due to a unfinished matching process. - if (nextStepTemplate && nextStepTemplate.isActive) { + if (nextStepTemplate) { const createdUserAchievement = await prisma.user_achievement.create({ data: { userId: userId, diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 4f9466f25..df70a5e32 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -13,12 +13,13 @@ const logger = getLogger('Achievement'); export const evaluateAchievement = tracer.wrap('achievement.evaluateAchievement', _evaluateAchievement); async function _evaluateAchievement( + userId: string, condition: string, dataAggregation: ConditionDataAggregations, metrics: string[], recordValue: number ): Promise { - const achievementEvents = await prisma.achievement_event.findMany({ where: { metric: { in: metrics } }, orderBy: { createdAt: 'desc' } }); + const achievementEvents = await prisma.achievement_event.findMany({ where: { userId, metric: { in: metrics } }, orderBy: { createdAt: 'desc' } }); const eventsByMetric: Record = {}; for (const event of achievementEvents) { @@ -44,9 +45,6 @@ async function _evaluateAchievement( const aggregator = dataAggregationObject.aggregator; const eventsForMetric = eventsByMetric[metricName]; - if (!eventsForMetric) { - continue; - } // we take the relation from the first event, that posesses one, in order to create buckets from it, if needed const relation = eventsForMetric.find((event) => event.relation)?.relation; @@ -88,13 +86,15 @@ async function _evaluateAchievement( export function createBucketEvents(events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] { switch (bucketConfig.bucketKind) { case 'default': - return createDefaultBuckets(events, bucketConfig); + return createDefaultBuckets(events); case 'time': return createTimeBuckets(events, bucketConfig); + default: + return createDefaultBuckets(events); } } -const createDefaultBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { +const createDefaultBuckets = (events: Achievement_event[]): BucketEvents[] => { return events.map((event) => ({ kind: 'default', events: [event], diff --git a/common/achievement/get.ts b/common/achievement/get.ts index f240de367..d09334998 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -10,7 +10,7 @@ import { evaluateAchievement } from './evaluate'; // TODO: resolver for nextSteps -> get active sequential achievements and important information const getUserAchievements = async (user: User): Promise => { const userAchievements = await prisma.user_achievement.findMany({ - where: { userId: user.userID }, + where: { userId: user.userID, AND: { template: { isActive: true } } }, include: { template: true }, }); const userAchievementGroups: { [group: string]: User_achievement[] } = {}; @@ -70,6 +70,7 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations); const evaluationResult = await evaluateAchievement( + user.userID, condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, currentAchievementTemplate.metrics, diff --git a/common/achievement/index.ts b/common/achievement/index.ts index a908e1292..3e90a8fde 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -1,23 +1,29 @@ import { prisma } from '../prisma'; import { User } from '../user'; -import { isGamificationFeatureActive, getMetricsByAction, sortActionTemplatesToGroups } from './util'; +import { sortActionTemplatesToGroups } from './util'; import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; -import { getTemplatesByAction } from './template'; +import { getTemplatesByMetrics } from './template'; import { evaluateAchievement } from './evaluate'; -import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementContext, UserAchievementTemplate } from './types'; +import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementTemplate } from './types'; import { createAchievement, getOrCreateUserAchievement } from './create'; +// eslint-disable-next-line import/no-cycle import { actionTakenAt } from '../notification'; import tracer from '../logger/tracing'; +import { getMetricsByAction } from './metrics'; +import { achievement_type_enum } from '../../graphql/generated'; +import { isGamificationFeatureActive } from '../../utils/environment'; const logger = getLogger('Achievement'); export const rewardActionTaken = tracer.wrap('achievement.rewardActionTaken', _rewardActionTaken); async function _rewardActionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { if (!isGamificationFeatureActive()) { + logger.warn(`Gamification feature is not active`); return; } - const templatesForAction = await tracer.trace('achievement.getTemplatesByAction', () => getTemplatesByAction(actionId)); + const metricsForAction = getMetricsByAction(actionId); + const templatesForAction = await tracer.trace('achievement.getTemplatesByMetrics', () => getTemplatesByMetrics(metricsForAction)); if (templatesForAction.length === 0) { logger.debug(`No achievement found for action '${actionId}'`); @@ -32,36 +38,46 @@ async function _rewardActionTaken(user: User, actionId: ID, user: user, context, }; - await tracer.trace('achievement.trackEvent', () => trackEvent(actionEvent)); + const isEventTracked = await tracer.trace('achievement.trackEvent', () => trackEvent(actionEvent)); + if (!isEventTracked) { + logger.warn(`Can't track event for action '${actionId}' for user '${user.userID}'`); + return; + } for (const [groupName, group] of templatesByGroups) { - await tracer.trace('achievement.evaluateAchievementGroups', async (span) => { - span.setTag('achievement.group', groupName); - let achievementToCheck: AchievementToCheck; - for (const template of group) { - const userAchievement = await tracer.trace('achievement.getOrCreateUserAchievement', () => - getOrCreateUserAchievement(template, user.userID, context) - ); - if (userAchievement.achievedAt === null || userAchievement.recordValue) { - achievementToCheck = userAchievement; - break; + try { + await tracer.trace('achievement.evaluateAchievementGroups', async (span) => { + span.setTag('achievement.group', groupName); + let achievementToCheck: AchievementToCheck; + for (const template of group) { + const userAchievement = await tracer.trace('achievement.getOrCreateUserAchievement', () => + getOrCreateUserAchievement(template, user.userID, context) + ); + if (userAchievement.achievedAt === null || userAchievement.template.type === achievement_type_enum.STREAK) { + achievementToCheck = userAchievement; + break; + } } - } - span.setTag('achievement.foundToCheck', !!achievementToCheck); - if (achievementToCheck) { - span.setTag('achievement.id', achievementToCheck.id); - await tracer.trace('achievement.checkUserAchievement', () => checkUserAchievement(achievementToCheck as UserAchievementTemplate, actionEvent)); - } - }); + span.setTag('achievement.foundToCheck', !!achievementToCheck); + if (achievementToCheck) { + span.setTag('achievement.id', achievementToCheck.id); + await tracer.trace('achievement.checkUserAchievement', () => + checkUserAchievement(achievementToCheck as UserAchievementTemplate, actionEvent) + ); + } + }); + } catch (e) { + logger.error(`Error occurred while checking achievement for user '${user.userID}'`, e); + } } } async function trackEvent(event: ActionEvent) { const metricsForEvent = getMetricsByAction(event.actionId); - if (!metricsForEvent) { - logger.debug(`Can't track event, because no metrics found for action '${event.actionId}'`); - return; + if (metricsForEvent.length === 0) { + logger.warn(`Can't track event, because no metrics found for action '${event.actionId}'`); + return false; } for (const metric of metricsForEvent) { @@ -75,6 +91,7 @@ async function trackEvent(event: ActionEvent) { action: event.actionId, userId: event.user.userID, relation: event.context.relation ?? '', + createdAt: event.at, }, }); } @@ -91,8 +108,7 @@ async function checkUserAchievement(userAchievement: UserAc const evaluationResultValue = typeof evaluationResult.resultObject[dataAggregationKey] === 'number' ? Number(evaluationResult.resultObject[dataAggregationKey]) : null; const awardedAchievement = await rewardUser(evaluationResultValue, userAchievement, event); - const userAchievementContext: UserAchievementContext = {}; - await createAchievement(awardedAchievement.template, userAchievement.userId, userAchievementContext); + await createAchievement(awardedAchievement.template, userAchievement.userId, event.context); } else { await prisma.user_achievement.update({ where: { id: userAchievement.id }, @@ -103,14 +119,21 @@ async function checkUserAchievement(userAchievement: UserAc async function isAchievementConditionMet(achievement: UserAchievementTemplate) { const { + userId, recordValue, template: { condition, conditionDataAggregations, metrics }, } = achievement; if (!condition) { - return; + logger.error(`No condition found for achievement ${achievement.template.name}`); } - const { conditionIsMet, resultObject } = await evaluateAchievement(condition, conditionDataAggregations as ConditionDataAggregations, metrics, recordValue); + const { conditionIsMet, resultObject } = await evaluateAchievement( + userId, + condition, + conditionDataAggregations as ConditionDataAggregations, + metrics, + recordValue + ); return { conditionIsMet, resultObject }; } diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts index b80bd7f75..e03b0fb31 100644 --- a/common/achievement/metrics.ts +++ b/common/achievement/metrics.ts @@ -1,11 +1,14 @@ import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html import { ActionID } from '../notification/actions'; import { Metric } from './types'; export const metricByName: Map = new Map(); -export const metricsByAction: Map = new Map(); +const metricsByAction: Map = new Map(); -export const metricExists = (metricName: string) => metricByName.has(metricName); +export function getMetricsByAction(actionId: ID): Metric[] { + return metricsByAction.get(actionId) || []; +} function registerMetric(metric: Metric) { const { metricName, onActions } = metric; @@ -26,10 +29,6 @@ function registerMetric(metric: Metric) { export function registerAllMetrics(metrics: Metric[]) { metrics.forEach((metric) => { - const { metricName } = metric; - if (metricExists(metricName)) { - throw new Error(`Metric '${metricName}' may only be registered once`); - } registerMetric(metric); }); } diff --git a/common/achievement/template.ts b/common/achievement/template.ts index f3555d3d6..603a7d545 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -1,10 +1,9 @@ import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html import { Achievement_template } from '../../graphql/generated'; import { getLogger } from '../logger/logger'; -import { ActionID } from '../notification/actions'; import { prisma } from '../prisma'; -import { metricsByAction } from './metrics'; -import { getMetricsByAction } from './util'; +import { Metric } from './types'; const logger = getLogger('Achievement Template'); @@ -41,44 +40,25 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise(actionId: ID) { +async function getTemplatesByMetrics(metricsForAction: Metric[]) { const templatesByMetric = await getAchievementTemplates(TemplateSelectEnum.BY_METRIC); - if (!Object.keys(templatesByMetric)) { - logger.warn(`No achievement templates were found in the database for the action with id: ${actionId}`); + if (Object.keys(templatesByMetric).length === 0) { + logger.debug(`No achievement templates were found in the database for the metrics: ${metricsForAction.map((m) => `${m.metricName}, `)}`); return []; } - const metricsForAction = metricsByAction.get(actionId); - let templatesForAction: Achievement_template[] = []; if (!metricsForAction || !templatesByMetric) { return []; - } else { - for (const metric of metricsForAction) { - templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; - } - return templatesForAction; } -} - -async function doesTemplateExistForAction(actionId: ID): Promise { - const metrics = getMetricsByAction(actionId); - const achievements = await getAchievementTemplates(TemplateSelectEnum.BY_METRIC); - for (const metric of metrics) { - if (achievements.has(metric.metricName)) { - return true; - } + for (const metric of metricsForAction) { + templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; } - - return false; -} - -function isMetricExistingForActionId(actionId: ID): boolean { - return metricsByAction.has(actionId); + return templatesForAction; } -export { isMetricExistingForActionId, getAchievementTemplates, doesTemplateExistForAction, getTemplatesByAction }; +export { getAchievementTemplates, getTemplatesByMetrics }; diff --git a/common/achievement/types.ts b/common/achievement/types.ts index dd3678f07..391f4d89c 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,5 +1,4 @@ -import { Prisma } from '@prisma/client'; -import { Achievement_event, Achievement_template, Lecture } from '../../graphql/generated'; +import { Achievement_event, Achievement_template, Lecture, User_achievement } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { User } from '../user'; @@ -89,13 +88,7 @@ export type ActionEvent = { context: SpecificNotificationContext; }; -export type AchievementToCheck = { - userId: string; - id: number; - achievedAt: Date; - context: Prisma.JsonValue; - template: Achievement_template; -}; +export type AchievementToCheck = Pick; export type EvaluationResult = { conditionIsMet: boolean; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index a5c50425a..05689d63b 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -1,29 +1,13 @@ -import { ActionID } from '../notification/types'; -import { metricsByAction } from './metrics'; -import { Metric, AchievementContextType, RelationTypes } from './types'; +import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html +import { AchievementContextType, RelationTypes } from './types'; import { prisma } from '../prisma'; -import { getLogger } from '../logger/logger'; import { Prisma } from '@prisma/client'; import { achievement_state } from '../../graphql/types/achievement'; import { User } from '../user'; import { Achievement_template, User_achievement } from '../../graphql/generated'; import { renderTemplate } from '../../utils/helpers'; -const logger = getLogger('Gamification'); -export function isGamificationFeatureActive(): boolean { - const isActive: boolean = JSON.parse(process.env.GAMIFICATION_ACTIVE || 'false'); - - if (!isActive) { - logger.warn('Gamification is deactivated'); - } - - return isActive; -} - -export function getMetricsByAction(actionId: ID): Metric[] { - return metricsByAction.get(actionId) || []; -} - export function getRelationTypeAndId(relation: string): [type: RelationTypes, id: number] { const validRelationTypes = ['match', 'subcourse']; const [relationType, id] = relation.split('/'); diff --git a/common/notification/actions.ts b/common/notification/actions.ts index e3dd39983..5f25d1cf7 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -654,21 +654,7 @@ const _notificationActions = { sampleContext: {}, }, - user_achievemnet_reward_issued: { - description: 'Reward issued', - sampleContext: { - achievement: { - id: '0', - name: 'achievement', - }, - }, - }, - /* MEETINGS */ - student_joined_meeting: { - description: 'Student joined meeting', - sampleContext: {}, - }, student_joined_match_meeting: { description: 'Student joined a match meeting', sampleContext: { diff --git a/common/notification/index.ts b/common/notification/index.ts index 305d22949..cc655dd77 100644 --- a/common/notification/index.ts +++ b/common/notification/index.ts @@ -15,6 +15,7 @@ import { ALL_PREFERENCES } from './defaultPreferences'; import assert from 'assert'; import { Prisma } from '@prisma/client'; import tracer, { addTagsToActiveSpan } from '../logger/tracing'; +// eslint-disable-next-line import/no-cycle import * as Achievement from '../../common/achievement'; const logger = getLogger('Notification'); diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts index 63c7411e1..cd1205521 100644 --- a/graphql/achievement/mutations.ts +++ b/graphql/achievement/mutations.ts @@ -30,16 +30,4 @@ export class MutateAchievementResolver { return true; } - - // ! - Just for testing - @Mutation((returns) => Boolean) - @Authorized(Role.ADMIN, Role.USER) - async verifiedEmail(@Ctx() context: GraphQLContext) { - if (context.user.studentId) { - await Notification.actionTaken(context.user, 'student_registration_verified_email', {}); - } else if (context.user.pupilId) { - await Notification.actionTaken(context.user, 'pupil_registration_verified_email', {}); - } - return true; - } } diff --git a/graphql/ownership.ts b/graphql/ownership.ts index d8989b5ae..6f0a1ef69 100644 --- a/graphql/ownership.ts +++ b/graphql/ownership.ts @@ -41,4 +41,5 @@ export const isOwnedBy: { [Name in ResolverModelNames]?: (user: GraphQLUser, ent Match: (user, match) => user.pupilId === match.pupilId || user.studentId === match.studentId, Concrete_notification: (user, concreteNotification) => concreteNotification.userId === user.userID, Participation_certificate: (user, certificate) => user.pupilId === certificate.pupilId || user.studentId === certificate.studentId, + User_achievement: (user, achievement) => user.userID === achievement.userId, }; diff --git a/graphql/user/mutations.ts b/graphql/user/mutations.ts index 85e186017..d3ce635ce 100644 --- a/graphql/user/mutations.ts +++ b/graphql/user/mutations.ts @@ -1,4 +1,4 @@ -import { Role } from '../authorizations'; +import { AuthorizedDeferred, Role, hasAccess } from '../authorizations'; import { RateLimit } from '../rate-limit'; import { Mutation, Resolver, Arg, Authorized, Ctx, InputType, Field } from 'type-graphql'; import { UserType } from '../types/user'; @@ -74,12 +74,13 @@ export class MutateUserResolver { return true; } @Mutation(() => Boolean) - @Authorized(Role.USER) - async markAchievementAsSeen(@Arg('achievementId') achievementId: number) { - await prisma.user_achievement.update({ + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async markAchievementAsSeen(@Ctx() context: GraphQLContext, @Arg('achievementId') achievementId: number) { + const acheivement = await prisma.user_achievement.update({ where: { id: achievementId }, data: { isSeen: true }, }); + await hasAccess(context, 'User_achievement', acheivement); return true; } } diff --git a/utils/environment.ts b/utils/environment.ts index 6f3d3cd08..b9559cbb2 100644 --- a/utils/environment.ts +++ b/utils/environment.ts @@ -17,3 +17,7 @@ export function getDDEnvironment(): string { export function getHostname(): string { return process.env.DD_HOSTNAME || 'n/a'; } + +export function isGamificationFeatureActive(): boolean { + return JSON.parse(process.env.GAMIFICATION_ACTIVE || 'false'); +}