From 31d45ea6b3de52c3f76eb6325c945f3ddc736e9a Mon Sep 17 00:00:00 2001 From: LomyW Date: Fri, 20 Oct 2023 10:21:11 +0200 Subject: [PATCH 01/58] add Readme --- common/achievement/Readme.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 common/achievement/Readme.md diff --git a/common/achievement/Readme.md b/common/achievement/Readme.md new file mode 100644 index 000000000..76da88512 --- /dev/null +++ b/common/achievement/Readme.md @@ -0,0 +1,4 @@ +# Achievement + +Achievements are a part of the Gamification. +Documentation follows... From d26339b52d553ff16f21fdeaf18e0ed95ffbead5 Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Wed, 15 Nov 2023 08:48:40 +0100 Subject: [PATCH 02/58] feat: achievement.actionTaken() (#887) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * add map for metric by name and by action --- common/achievement/index.ts | 33 ++++++++++++++++++ common/achievement/metric.ts | 30 +++++++++++++++-- common/achievement/metrics.ts | 34 +++++++++++++++++++ common/achievement/template.ts | 61 ++++++++++++++++++++++++++++++++++ common/achievement/types.ts | 37 ++++++--------------- common/notification/index.ts | 3 ++ 6 files changed, 170 insertions(+), 28 deletions(-) create mode 100644 common/achievement/index.ts create mode 100644 common/achievement/metrics.ts create mode 100644 common/achievement/template.ts diff --git a/common/achievement/index.ts b/common/achievement/index.ts new file mode 100644 index 000000000..2b5c48ff4 --- /dev/null +++ b/common/achievement/index.ts @@ -0,0 +1,33 @@ +import { getLogger } from '../logger/logger'; +import { ActionID, SpecificNotificationContext } from '../notification/actions'; +import { User } from '../user'; +import { doesTemplateExistForAction } from './template'; + +const logger = getLogger('Achievement'); + +export type ActionEvent = { + action: string; + at: Date; + user: User; +}; + +export async function actionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { + const templateExists = await doesTemplateExistForAction(actionId); + + if (!templateExists) { + logger.debug(`No achievement found for action '${actionId}'`); + return; + } + + // TODO: create Event + const event: ActionEvent = { + action: actionId, + at: new Date(), + user: user, + }; + + // TODO: track event(event) + // TODO: checkAwardAchievement + + return null; +} diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index b9f11bcd2..85943206e 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -1,4 +1,30 @@ +import { ActionID, SpecificNotificationContext } from '../notification/actions'; +import { registerAllMetrics } from './metrics'; import { Metric } from './types'; -// Metric is the connection of the action that happens with the formula for calculating the value for the event -const metrics: Metric[] = []; +// Maps A | B to A & B (using contra-variant position - c.f. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types) +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +// Derives the context for a given list of ActionIDs +type ContextForActions = UnionToIntersection< + // By using UnionToIntersection, it combines specific contexts for the provided ActionIDs + // Creating a union of all specific contexts for the given ActionIDs + { [Index in keyof ActionIDs]: SpecificNotificationContext }[number] +>; + +// This function utilizes generics to ensure flexibility in the ActionIDs and their respective contexts, allowing for dynamic metric creation. +function createMetric>(metricName: string, onActions: T, formula: (context: K) => number): Metric { + return { + metricName, + onActions, + formula, + }; +} + +const batchOfMetrics = [ + createMetric('student_onboarding_registered', ['student_registration_started'], (context) => { + return 1; + }), +]; + +registerAllMetrics(batchOfMetrics); diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts new file mode 100644 index 000000000..ddb630273 --- /dev/null +++ b/common/achievement/metrics.ts @@ -0,0 +1,34 @@ +import { ActionID } from '../notification/actions'; +import { Metric } from './types'; + +export const metricByName: Map = new Map(); +export const metricsByAction: Map = new Map(); + +export const metricExists = (metricName: string) => metricByName.has(metricName); + +function registerMetric(metric: Metric) { + const { metricName, onActions } = metric; + + if (metricByName.has(metricName)) { + throw new Error(`Metric '${metricName}' may only be registered once`); + } + + onActions.forEach((actionID) => { + if (!metricsByAction.has(actionID)) { + metricsByAction.set(actionID, []); + } + metricsByAction.get(actionID).push(metric); + }); + + metricByName.set(metricName, 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 new file mode 100644 index 000000000..56fd53b4b --- /dev/null +++ b/common/achievement/template.ts @@ -0,0 +1,61 @@ +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 { Metric } from './types'; + +const logger = getLogger('Achievement Template'); + +type AchievementTemplatesMap = Map[]>; +let _achievementTemplates: Promise; + +function getAchievementTemplates(): Promise { + if (_achievementTemplates === undefined) { + _achievementTemplates = (async function () { + const result = new Map[]>(); + + const achievementTemplates = await prisma.achievement_template.findMany({ + where: { isActive: true }, + }); + + for (const template of achievementTemplates) { + for (const metric of template.metrics) { + if (!result.has(metric)) { + result.set(metric, []); + } + + result.get(metric).push(template); + } + } + + logger.debug(`Loaded ${achievementTemplates.length} achievement templates into the cache`); + + return result; + })(); + } + + return _achievementTemplates; +} + +async function doesTemplateExistForAction(actionId: ID): Promise { + const metrics = getMetricsByAction(actionId); + const templates = await getAchievementTemplates(); + for (const metric of metrics) { + if (templates.has(metric.metricName)) { + return true; + } + } + + return false; +} + +function getMetricsByAction(actionId: ID): Metric[] { + return metricsByAction.get(actionId) || []; +} + +function isMetricExistingForActionId(actionId: ID): boolean { + return metricsByAction.has(actionId); +} + +export { isMetricExistingForActionId, getAchievementTemplates, doesTemplateExistForAction }; diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 42a6e62eb..5783c1b85 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,34 +1,19 @@ +import { ActionID, SpecificNotificationContext } from '../notification/actions'; + +// type ActionIDUnion = A[number]; +// formula: FormulaFunction>; + export type Metric = { - id: number; metricName: string; - onActions: string[]; - formula: FormulaFunction; + onActions: ActionID[]; + formula: FormulaFunction; }; -export type EventValue = number | string | boolean; +export type FormulaFunction = (context: SpecificNotificationContext) => number; -export type FormulaContext = { - subcourse?: { - lectures: { - start: Date; - }[]; - }; - match?: { - lectures: { - start: Date; - }[]; - }; - appointment?: { - id: number; - duration?: number; - match?: number; - subcourse?: number; - }; -}; - -export type FormulaFunction = (context: FormulaContext) => number; +export type EventValue = number | string | boolean; -// A bucket is seen as a period of time +// A bucket is seen as for a period of time export interface Bucket { startTime: Date; endTime: Date; @@ -41,7 +26,7 @@ export interface BucketEventsWithAggr extends BucketEvents { aggregation: EventValue; } -type BucketFormulaFunction = (context: FormulaContext) => Bucket[]; +type BucketFormulaFunction = (relation: string) => Bucket[]; export type BucketFormula = { function: BucketFormulaFunction; diff --git a/common/notification/index.ts b/common/notification/index.ts index 9fbf570f0..be488c071 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 { addTagsToActiveSpan } from '../logger/tracing'; +import * as Achievement from '../../common/achievement'; const logger = getLogger('Notification'); @@ -401,6 +402,8 @@ export async function actionTaken( return; } + await Achievement.actionTaken(user, actionId, notificationContext); + return await actionTakenAt(new Date(), user, actionId, notificationContext, false, noDuplicates, attachments); } From ce66a593c839f0e124061aad17ab56b799f59707 Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Fri, 17 Nov 2023 10:40:54 +0100 Subject: [PATCH 03/58] feat: track achievement event (#892) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map --- common/achievement/index.ts | 48 ++++++++++++++++++++++++++++++---- common/achievement/template.ts | 5 +--- common/achievement/util.ts | 28 ++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 common/achievement/util.ts diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 2b5c48ff4..1e7afd6f6 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -1,16 +1,27 @@ +import { prisma } from '../prisma'; +import { User } from '../user'; +import { EventValue } from './types'; +import { getMetricsByAction, getRelationByContext } from './util'; import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; -import { User } from '../user'; +import { NotificationContext } from '../notification/types'; +import { Achievement_event } from '../../graphql/generated'; import { doesTemplateExistForAction } from './template'; const logger = getLogger('Achievement'); export type ActionEvent = { - action: string; + actionId: ActionID; at: Date; user: User; }; - +export type Achievement_Event = { + userId?: string; + metric: string; + value: EventValue; + action?: string; + relation?: string; // e.g. "user/10", "subcourse/15", "match/20" +}; export async function actionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { const templateExists = await doesTemplateExistForAction(actionId); @@ -19,15 +30,42 @@ export async function actionTaken(user: User, actionId: ID, return; } - // TODO: create Event const event: ActionEvent = { - action: actionId, + actionId, at: new Date(), user: user, }; + const tracked = await trackEvent(event, context); + // TODO: check if user achievement already exists // TODO: track event(event) // TODO: checkAwardAchievement return null; } + +async function trackEvent(event: ActionEvent, context: NotificationContext) { + const metricsForEvent = getMetricsByAction(event.actionId); + + if (!metricsForEvent) { + logger.debug(`Can't track event, because no metrics found for action '${event.actionId}'`); + return; + } + + for (const metric of metricsForEvent) { + const formula = metric.formula; + const value = formula(context); + + await prisma.achievement_event.create({ + data: { + metric: metric.metricName, + value: value, + action: event.actionId, + userId: event.user.userID, + relation: getRelationByContext(context), + }, + }); + } + + return true; +} diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 56fd53b4b..82a9502ab 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -4,6 +4,7 @@ import { ActionID } from '../notification/actions'; import { prisma } from '../prisma'; import { metricsByAction } from './metrics'; import { Metric } from './types'; +import { getMetricsByAction } from './util'; const logger = getLogger('Achievement Template'); @@ -50,10 +51,6 @@ async function doesTemplateExistForAction(actionId: ID): Pr return false; } -function getMetricsByAction(actionId: ID): Metric[] { - return metricsByAction.get(actionId) || []; -} - function isMetricExistingForActionId(actionId: ID): boolean { return metricsByAction.has(actionId); } diff --git a/common/achievement/util.ts b/common/achievement/util.ts new file mode 100644 index 000000000..92d845408 --- /dev/null +++ b/common/achievement/util.ts @@ -0,0 +1,28 @@ +import { ActionID, NotificationContext } from '../notification/types'; +import { metricsByAction } from './metrics'; +import { Metric } from './types'; + +export function getRelationByContext(context: NotificationContext): string { + const { appointment, match, subcourse } = context; + let relation: string; + + switch (context) { + case context.match: + relation = `match/${match.id}`; + break; + case context.subcourse: + relation = `subcourse/${subcourse.id}`; + break; + case context.appointment: + relation = `appointment/${appointment.id}`; + break; + default: + relation = ''; + } + + return relation; +} + +export function getMetricsByAction(actionId: ID): Metric[] { + return metricsByAction.get(actionId) || []; +} From aaa8f0ea717c92ec0dd3b76e05da7c0e7712dfde Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Mon, 27 Nov 2023 07:48:05 +0100 Subject: [PATCH 04/58] feat: achievement standard aggregators (#893) first draft --- common/achievement/aggregator.ts | 14 +++++++++++++- common/achievement/types.ts | 8 ++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/common/achievement/aggregator.ts b/common/achievement/aggregator.ts index 946ef8871..502f14837 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -1,7 +1,19 @@ +import { Achievement_event } from '../../graphql/generated'; import { AggregatorFunction } from './types'; type Aggregator = Record; // Aggregators are needed to aggregate event values (achievement_event.value) or buckets for evaluation (like sum, count, max, min, avg) -export const aggregators: Aggregator = {}; +export const aggregators: Aggregator = { + sum: { + function: (values: number[]): number => { + return values.reduce((total, num) => total + num, 0); + }, + }, + count: { + function: (events: Achievement_event[]): number => { + return events.length; + }, + }, +}; diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 5783c1b85..6c11fe393 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,3 +1,4 @@ +import { Achievement_event } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; // type ActionIDUnion = A[number]; @@ -9,10 +10,9 @@ export type Metric = { formula: FormulaFunction; }; +export type EventValue = number[] | Achievement_event[]; export type FormulaFunction = (context: SpecificNotificationContext) => number; -export type EventValue = number | string | boolean; - // A bucket is seen as for a period of time export interface Bucket { startTime: Date; @@ -23,7 +23,7 @@ export interface BucketEvents extends Bucket { events: TrackEvent[]; } export interface BucketEventsWithAggr extends BucketEvents { - aggregation: EventValue; + aggregation: number; } type BucketFormulaFunction = (relation: string) => Bucket[]; @@ -33,7 +33,7 @@ export type BucketFormula = { }; export type AggregatorFunction = { - function: (elements: EventValue[]) => number; + function: (elements: EventValue) => number; }; export type ConditionDataAggregations = { From ce7257e1fce0558ae9d4244bd4331d8d816a2110 Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:37:19 +0100 Subject: [PATCH 05/58] feat: evaluation of achievements (#898) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map * add first bucket creators * add package swan * add relation resolution * add first draft to evaluate * add function to check for achievements * add achievements by group * add create user achievement * adjusted evaluation * some drafts * adjust create sequential user achievement * add feature flag * add test actions and mutations * add achieved image to db * adjust next step index * fix awarding user * seed db example type swan js parser * feat: evaluation of tiered achievements (#910) feat: filter bucket with achievement creation * feat: evaluation of streaks (#909) * feat: filter bucket with achievement creation * fix: branch update * fix: create time buckets returning buckets * feat: streak evaluation * fix: update streak with correct values * fix: merge clearing * fix inject record value * fix: make it work * fix: work in change requests * fix: safe empty json in db * fix: requested changes * fix: add condition for relation Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: remove test data, change requests * feat: refactor of aggregators (#926) * refactor of aggregators * review changes * rename streak aggregator --------- Co-authored-by: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Co-authored-by: Lucas --- .env.example | 3 +- common/achievement/aggregator.ts | 27 +++- common/achievement/bucket.ts | 76 +++++++++++- common/achievement/create.ts | 65 ++++++++++ common/achievement/evaluate.ts | 117 ++++++++++++++++++ common/achievement/helper.ts | 24 ++++ common/achievement/index.ts | 101 +++++++++++---- common/achievement/metric.ts | 20 ++- common/achievement/template.ts | 68 ++++++---- common/achievement/types.ts | 98 +++++++++++++-- common/achievement/util.ts | 60 ++++++--- common/notification/actions.ts | 22 ++++ common/notification/types.ts | 2 + jobs/index.ts | 4 + package-lock.json | 39 +++++- package.json | 1 + .../migration.sql | 11 ++ prisma/schema.prisma | 3 +- seed-db.ts | 1 + types/custom.d.ts | 4 + web/index.ts | 2 + 21 files changed, 652 insertions(+), 96 deletions(-) create mode 100644 common/achievement/create.ts create mode 100644 common/achievement/evaluate.ts create mode 100644 common/achievement/helper.ts create mode 100644 prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql create mode 100644 types/custom.d.ts diff --git a/.env.example b/.env.example index 25e211569..d790ee2cd 100644 --- a/.env.example +++ b/.env.example @@ -69,4 +69,5 @@ ZOOM_ACCOUNT_ID= ZOOM_MEETING_SDK_CLIENT_ID= ZOOM_MEETING_SDK_CLIENT_SECRET= -ZOOM_ACTIVE= \ No newline at end of file +ZOOM_ACTIVE= +GAMIFICATION_ACTIVE= \ No newline at end of file diff --git a/common/achievement/aggregator.ts b/common/achievement/aggregator.ts index 502f14837..3ea74d1c3 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -1,4 +1,3 @@ -import { Achievement_event } from '../../graphql/generated'; import { AggregatorFunction } from './types'; type Aggregator = Record; @@ -7,13 +6,31 @@ type Aggregator = Record; export const aggregators: Aggregator = { sum: { - function: (values: number[]): number => { - return values.reduce((total, num) => total + num, 0); + function: (elements): number => { + return elements.reduce((total, num) => total + num, 0); }, }, count: { - function: (events: Achievement_event[]): number => { - return events.length; + function: (elements): number => { + return elements.length; + }, + }, + // this aggregator should be used to check if min one event exist in a bucket, i.e. if one event happend in one week / one month + presenceOfEvents: { + function: (elements): number => { + return elements.length > 0 ? 1 : 0; + }, + }, + lastStreakLength: { + function: (elements): number => { + let value = 0; + for (const element of elements) { + if (element === 0) { + break; + } + value += 1; + } + return value; }, }, }; diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 287c657d3..596b66924 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,6 +1,78 @@ -import { BucketFormula } from './types'; +import moment from 'moment'; +import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket } from './types'; +import { getRelationContext } from './util'; type BucketCreatorDefs = Record; // Buckets are needed to pre-sort and aggregate certain events by types / a certain time window (e.g. weekly) etc. -export const bucketCreatorDefs: BucketCreatorDefs = {}; +export const bucketCreatorDefs: BucketCreatorDefs = { + default: { + function: async (): Promise> => { + return await { bucketKind: 'default', buckets: [] }; + }, + }, + by_lecture_start: { + function: async (relation: string): Promise> => { + if (!relation) { + return { bucketKind: 'time', buckets: [] }; + } + const context = await getRelationContext(relation); + if (!context[relation].lecture) { + return { bucketKind: 'time', buckets: [] }; + } + return { + bucketKind: 'time', + buckets: context[relation].lecture.map((lecture) => ({ + kind: 'time', + startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), + endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), + })), + }; + }, + }, + by_weeks: { + function: async (): Promise> => { + // TODO - where did we get the number of weeks + const weeks = 5; + const today = moment(); + const buckets: TimeBucket[] = []; + + for (let i = 0; i < weeks; i++) { + const weeksBefore = today.clone().subtract(i, 'week'); + buckets.push({ + kind: 'time', + startTime: weeksBefore.startOf('week').toDate(), + endTime: weeksBefore.endOf('week').toDate(), + }); + } + + return await { + bucketKind: 'time', + buckets, + }; + }, + }, + by_months: { + function: async (): Promise> => { + // TODO - where did we get the number of months + + const months = 12; + const today = moment(); + const buckets: TimeBucket[] = []; + + for (let i = 0; i < months; i++) { + const monthsBefore = today.clone().subtract(i, 'month'); + buckets.push({ + kind: 'time', + startTime: monthsBefore.startOf('month').toDate(), + endTime: monthsBefore.endOf('month').toDate(), + }); + } + + return await { + bucketKind: 'time', + buckets, + }; + }, + }, +}; diff --git a/common/achievement/create.ts b/common/achievement/create.ts new file mode 100644 index 000000000..c145f31b7 --- /dev/null +++ b/common/achievement/create.ts @@ -0,0 +1,65 @@ +import { Prisma } from '@prisma/client'; +import { Achievement_template } from '../../graphql/generated'; +import { prisma } from '../prisma'; +import { TemplateSelectEnum, getAchievementTemplates } from './template'; +import { UserAchievementContext } from './types'; + +async function doesUserAchievementAlreadyExist(templateId: number, userId: string) { + // TODO - check if user achievement exist for one match or one subcourse + const userAchievement = await prisma.user_achievement.findFirst({ + where: { + templateId, + userId, + }, + select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, + }); + return userAchievement; +} + +async function getOrCreateUserAchievement(template: Achievement_template, userId: string, context: UserAchievementContext) { + const existingUserAchievement = await doesUserAchievementAlreadyExist(template.id, userId); + if (!existingUserAchievement) { + return await createAchievement(template, userId, context); + } + return existingUserAchievement; +} + +async function createAchievement(templateToCreate: Achievement_template, userId: string, context: UserAchievementContext) { + const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + const userAchievementsByGroup = await prisma.user_achievement.findMany({ + where: { template: { group: templateToCreate.group } }, + orderBy: { template: { groupOrder: 'asc' } }, + }); + + const nextStepIndex = userAchievementsByGroup.length > 0 ? templateToCreate.groupOrder + 1 : 1; + + const templatesForGroup = templatesByGroup.get(templateToCreate.group); + if (templatesForGroup && templatesForGroup.length >= nextStepIndex) { + const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); + return createdUserAchievement; + } +} + +async function createNextUserAchievement(templatesForGroup: Achievement_template[], nextStepIndex: number, userId: string, context: UserAchievementContext) { + const nextStepTemplate = templatesForGroup.find((template) => template.groupOrder === 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) { + const createdUserAchievement = await prisma.user_achievement.create({ + data: { + userId: userId, + group: nextStepTemplate.group, + groupOrder: nextStepTemplate.groupOrder, + context: context ? context : Prisma.JsonNull, + template: { connect: { id: nextStepTemplate.id } }, + recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null, + }, + select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, + }); + return createdUserAchievement; + } + const nextUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex + 1, userId, context); + return nextUserAchievement; +} + +export { getOrCreateUserAchievement, createAchievement }; diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts new file mode 100644 index 000000000..74f6a1851 --- /dev/null +++ b/common/achievement/evaluate.ts @@ -0,0 +1,117 @@ +import { Achievement_event } from '../../graphql/generated'; +import { BucketConfig, BucketEvents, BucketEventsWithAggr, ConditionDataAggregations, EvaluationResult } from './types'; +import { prisma } from '../prisma'; +import { aggregators } from './aggregator'; +import swan from '@onlabsorg/swan-js'; +import { bucketCreatorDefs } from './bucket'; + +export async function evaluateAchievement(condition: string, dataAggregation: ConditionDataAggregations, metrics: string[]): Promise { + const achievementEvents = await prisma.achievement_event.findMany({ where: { metric: { in: metrics } } }); + + const eventsByMetric: Record = {}; // Store events per metric + for (const event of achievementEvents) { + if (!eventsByMetric[event.metric]) { + eventsByMetric[event.metric] = []; + } + eventsByMetric[event.metric].push(event); + } + + const resultObject: Record = {}; + + for (const key in dataAggregation) { + if (!dataAggregation[key]) { + return; + } + const dataAggregationObject = dataAggregation[key]; + const metricName = dataAggregationObject.metric; + + const bucketCreator = dataAggregationObject.createBuckets || 'default'; + const bucketAggregator = dataAggregationObject.bucketAggregator || 'count'; + + const aggregator = dataAggregationObject.aggregator; + + const eventsForMetric = eventsByMetric[metricName]; + if (!eventsForMetric) { + return; + } + // 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; + + const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; + const bucketAggregatorFunction = aggregators[bucketAggregator].function; + const aggFunction = aggregators[aggregator].function; + + if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggFunction) { + return; + } + + const buckets = await bucketCreatorFunction(relation); + const bucketEvents = createBucketEvents(eventsForMetric, buckets); + + const bucketAggr = bucketEvents.map( + (bucketEvent): BucketEventsWithAggr => ({ + ...bucketEvent, + aggregation: bucketAggregatorFunction(bucketEvent.events.map((event) => event.value)), + }) + ); + + const valuesFromBucketAggr = bucketAggr.map((bucket) => bucket.aggregation); + + const value = aggFunction(valuesFromBucketAggr); + resultObject[key] = value; + } + + const evaluate = swan.parse(condition); + const value: boolean = await evaluate(resultObject); + + return { + conditionIsMet: value, + resultObject, + }; +} + +export function createBucketEvents(events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] { + switch (bucketConfig.bucketKind) { + case 'default': + return createDefaultBuckets(events, bucketConfig); + case 'time': + return createTimeBuckets(events, bucketConfig); + case 'filter': + return createFilterBuckets(events, bucketConfig); + } +} + +const createDefaultBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { + return events.map((event) => ({ + kind: 'default', + events: [event], + })); +}; + +const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { + const { buckets } = bucketConfig; + const bucketsWithEvents: BucketEvents[] = buckets.map((bucket) => { + const filteredEvents = events.filter((event) => { + return event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime; + }); + return { + kind: bucket.kind, + startTime: bucket.startTime, + endTime: bucket.endTime, + events: filteredEvents, + }; + }); + return bucketsWithEvents; +}; + +const createFilterBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { + const { buckets } = bucketConfig; + const filteredEvents = events.filter((event) => { + return buckets.some((bucket) => bucket.actionName === event.action); + }); + return filteredEvents.map((event) => ({ + kind: 'filter', + actionName: event.action, + events: [event], + })); +}; diff --git a/common/achievement/helper.ts b/common/achievement/helper.ts new file mode 100644 index 000000000..026bebfd3 --- /dev/null +++ b/common/achievement/helper.ts @@ -0,0 +1,24 @@ +import { Achievement_template } from '../../graphql/generated'; + +export function sortActionTemplatesToGroups(templatesForAction: Achievement_template[]) { + const templatesByGroups: Map = new Map(); + for (const template of templatesForAction) { + if (!templatesByGroups.has(template.group)) { + templatesByGroups.set(template.group, []); + } + templatesByGroups.get(template.group).push(template); + } + templatesByGroups.forEach((group, key) => { + group.sort((a, b) => a.groupOrder - b.groupOrder); + templatesByGroups.set(key, group); + }); + return templatesByGroups; +} + +// replace recordValue in condition with number of last record +export function injectRecordValue(condition: string, recordValue: number) { + if (typeof recordValue === 'number') { + return condition.replace('recordValue', recordValue.toString()); + } + return condition; +} diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 1e7afd6f6..eaff56fa5 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -1,50 +1,58 @@ import { prisma } from '../prisma'; import { User } from '../user'; -import { EventValue } from './types'; -import { getMetricsByAction, getRelationByContext } from './util'; +import { isGamificationFeatureActive, getMetricsByAction } from './util'; import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; -import { NotificationContext } from '../notification/types'; -import { Achievement_event } from '../../graphql/generated'; -import { doesTemplateExistForAction } from './template'; +import { getTemplatesByAction } from './template'; +import { evaluateAchievement } from './evaluate'; +import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementContext, UserAchievementTemplate } from './types'; +import { createAchievement, getOrCreateUserAchievement } from './create'; +import { Achievement_template } from '../../graphql/generated'; +import { Prisma } from '@prisma/client'; +import { injectRecordValue, sortActionTemplatesToGroups } from './helper'; const logger = getLogger('Achievement'); -export type ActionEvent = { - actionId: ActionID; - at: Date; - user: User; -}; -export type Achievement_Event = { - userId?: string; - metric: string; - value: EventValue; - action?: string; - relation?: string; // e.g. "user/10", "subcourse/15", "match/20" -}; export async function actionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { - const templateExists = await doesTemplateExistForAction(actionId); + if (!isGamificationFeatureActive()) { + return; + } + const templatesForAction = await getTemplatesByAction(actionId); - if (!templateExists) { + if (templatesForAction.length === 0) { logger.debug(`No achievement found for action '${actionId}'`); return; } - const event: ActionEvent = { + const templatesByGroups = sortActionTemplatesToGroups(templatesForAction); + + const event: ActionEvent = { actionId, at: new Date(), user: user, + context, }; + await trackEvent(event, context); - const tracked = await trackEvent(event, context); - // TODO: check if user achievement already exists - // TODO: track event(event) - // TODO: checkAwardAchievement + for (const [key, group] of templatesByGroups) { + let achievementToCheck: AchievementToCheck; + const context = {} as UserAchievementContext; + for (const template of group) { + const userAchievement = await getOrCreateUserAchievement(template, user.userID, context); + if (userAchievement.achievedAt === null || userAchievement.recordValue) { + achievementToCheck = userAchievement; + break; + } + } + if (achievementToCheck) { + await checkUserAchievement(achievementToCheck as UserAchievementTemplate); + } + } - return null; + return; } -async function trackEvent(event: ActionEvent, context: NotificationContext) { +async function trackEvent(event: ActionEvent, context: SpecificNotificationContext) { const metricsForEvent = getMetricsByAction(event.actionId); if (!metricsForEvent) { @@ -62,10 +70,49 @@ async function trackEvent(event: ActionEvent, context: NotificationContext) { value: value, action: event.actionId, userId: event.user.userID, - relation: getRelationByContext(context), + // TODO - get relation OR get relationId from context + relation: event.context.relationId ?? '', }, }); } return true; } + +async function checkUserAchievement(userAchievement: UserAchievementTemplate) { + const evaluationResult = await isAchievementConditionMet(userAchievement); + if (evaluationResult.conditionIsMet) { + const dataAggregationKey = Object.keys(userAchievement.template.conditionDataAggregations as ConditionDataAggregations)[0]; + const evaluationResultValue = + typeof evaluationResult.resultObject[dataAggregationKey] === 'number' ? Number(evaluationResult.resultObject[dataAggregationKey]) : null; + const awardedAchievement = await awardUser(evaluationResultValue, userAchievement); + const userAchievementContext: UserAchievementContext = {}; + await createAchievement(awardedAchievement.template, userAchievement.userId, userAchievementContext); + } +} + +async function isAchievementConditionMet(achievement: UserAchievementTemplate) { + const { + userId, + template: { condition, conditionDataAggregations, metrics }, + } = achievement; + if (!condition) { + return; + } + + const updatedCondition = injectRecordValue(condition, achievement.recordValue); + const { conditionIsMet, resultObject } = await evaluateAchievement(updatedCondition, conditionDataAggregations as ConditionDataAggregations, metrics); + return { conditionIsMet, resultObject }; +} + +async function awardUser(evaluationResult: number, userAchievement: UserAchievementTemplate) { + let newRecordValue = null; + if (typeof userAchievement.recordValue === 'number' && evaluationResult) { + newRecordValue = evaluationResult; + } + return await prisma.user_achievement.update({ + where: { id: userAchievement.id }, + data: { achievedAt: new Date(), recordValue: newRecordValue, isSeen: false }, + select: { id: true, userId: true, achievedAt: true, context: true, template: true }, + }); +} diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index 85943206e..9ee9d1e77 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -22,9 +22,25 @@ function createMetric>(metr } const batchOfMetrics = [ - createMetric('student_onboarding_registered', ['student_registration_started'], (context) => { + createMetric('onboarding_verified_email', ['user_registration_verified_email'], () => { + return 1; + }), + createMetric('onboarding_screening_events', ['tutor_screening_success', 'instructor_screening_success'], () => { + return 1; + }), + createMetric('onboarding_coc_event', ['student_coc_updated'], () => { + return 1; + }), + // student_conducted_match_appointment is a tiered achievement and therefore needs to be initialized, in this case with tutor_match_requested + // later requests to this metric will onle be conducted by joined_match_meeting. This is secured by the filter bucket + createMetric('student_conducted_match_appointment', ['tutor_match_requested', 'joined_match_meeting'], () => { + return 1; + }), + createMetric('weekly_presence', ['joined_meeting'], () => { return 1; }), ]; -registerAllMetrics(batchOfMetrics); +export function registerAchievementMetrics() { + registerAllMetrics(batchOfMetrics); +} diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 82a9502ab..ff32d6790 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -3,47 +3,69 @@ import { getLogger } from '../logger/logger'; import { ActionID } from '../notification/actions'; import { prisma } from '../prisma'; import { metricsByAction } from './metrics'; -import { Metric } from './types'; import { getMetricsByAction } from './util'; const logger = getLogger('Achievement Template'); -type AchievementTemplatesMap = Map[]>; -let _achievementTemplates: Promise; +export enum TemplateSelectEnum { + BY_GROUP = 'group', + BY_METRIC = 'metrics', +} -function getAchievementTemplates(): Promise { - if (_achievementTemplates === undefined) { - _achievementTemplates = (async function () { - const result = new Map[]>(); +// string == metricId, group +const achievementTemplates: Map> = new Map(); - const achievementTemplates = await prisma.achievement_template.findMany({ - where: { isActive: true }, - }); +async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { + if (!achievementTemplates.has(select)) { + achievementTemplates.set(select, new Map()); - for (const template of achievementTemplates) { - for (const metric of template.metrics) { - if (!result.has(metric)) { - result.set(metric, []); - } + const templatesFromDB = await prisma.achievement_template.findMany({ + where: { isActive: true }, + }); + + for (const template of templatesFromDB) { + const selection = template[select]; - result.get(metric).push(template); + if (Array.isArray(selection)) { + for (const value of selection) { + if (!achievementTemplates.get(select)?.has(value)) { + achievementTemplates.get(select)?.set(value, []); + } + achievementTemplates.get(select)?.get(value)?.push(template); + } + } else { + if (!achievementTemplates.get(select)?.has(selection)) { + achievementTemplates.get(select)?.set(selection, []); } + achievementTemplates.get(select)?.get(selection)?.push(template); } + } + logger.debug(`Loaded ${templatesFromDB.length} achievement templates into the cache`); + } + return achievementTemplates.get(select); +} - logger.debug(`Loaded ${achievementTemplates.length} achievement templates into the cache`); +async function getTemplatesByAction(actionId: ID) { + 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}`); + return []; + } + const metricsForAction = metricsByAction.get(actionId); - return result; - })(); + let templatesForAction: Achievement_template[] = []; + for (const metric of metricsForAction) { + templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; } - return _achievementTemplates; + return templatesForAction; } async function doesTemplateExistForAction(actionId: ID): Promise { const metrics = getMetricsByAction(actionId); - const templates = await getAchievementTemplates(); + const achievements = await getAchievementTemplates(TemplateSelectEnum.BY_METRIC); for (const metric of metrics) { - if (templates.has(metric.metricName)) { + if (achievements.has(metric.metricName)) { return true; } } @@ -55,4 +77,4 @@ function isMetricExistingForActionId(actionId: ID): boolean return metricsByAction.has(actionId); } -export { isMetricExistingForActionId, getAchievementTemplates, doesTemplateExistForAction }; +export { isMetricExistingForActionId, getAchievementTemplates, doesTemplateExistForAction, getTemplatesByAction }; diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 6c11fe393..98056480f 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,5 +1,7 @@ -import { Achievement_event } from '../../graphql/generated'; +import { Prisma } from '@prisma/client'; +import { Achievement_event, Achievement_template, achievement_type_enum } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; +import { User } from '../user'; // type ActionIDUnion = A[number]; // formula: FormulaFunction>; @@ -10,35 +12,53 @@ export type Metric = { formula: FormulaFunction; }; -export type EventValue = number[] | Achievement_event[]; export type FormulaFunction = (context: SpecificNotificationContext) => number; -// A bucket is seen as for a period of time -export interface Bucket { +// Used to destinguish between different types of buckets +export type GenericBucketConfig = { + bucketKind: T['kind']; + buckets: T[]; +}; +// Combines all possible bucket configs +export type BucketConfig = GenericBucketConfig | GenericBucketConfig | GenericBucketConfig; + +export type DefaultBucket = { + kind: 'default'; +}; +// Bucket containing events from a specific time frame +export type TimeBucket = { + kind: 'time'; startTime: Date; endTime: Date; -} +}; +// Bucket containing events that match a specific actionName +export type FilterBucket = { + kind: 'filter'; + actionName: string; +}; +// A bucket is seen as for a period of time +export type Bucket = DefaultBucket | TimeBucket | FilterBucket; -export interface BucketEvents extends Bucket { - events: TrackEvent[]; -} -export interface BucketEventsWithAggr extends BucketEvents { +export type BucketEvents = Bucket & { + events: Achievement_event[]; +}; +export type BucketEventsWithAggr = BucketEvents & { aggregation: number; -} +}; -type BucketFormulaFunction = (relation: string) => Bucket[]; +type BucketFormulaFunction = (relation?: string) => Promise; export type BucketFormula = { function: BucketFormulaFunction; }; export type AggregatorFunction = { - function: (elements: EventValue) => number; + function: (elements: number[]) => number; }; export type ConditionDataAggregations = { [key: string]: { - metricId: number; + metric: string; aggregator: string; // These two are used to first create all the needed buckets and then aggregate the events that fall into these // Default: count @@ -47,3 +67,55 @@ export type ConditionDataAggregations = { createBuckets?: string; }; }; + +export type UserAchievementContext = { + matchId?: number; + subcourseId?: number; + match_partner?: string; +}; + +export type UserAchievementTemplate = { + id: number; + userId: string; + achievedAt: Date; + context: UserAchievementContext; + template: Achievement_template; + recordValue?: number; +}; + +export type ActionEvent = { + actionId: ActionID; + at: Date; + user: User; + context: SpecificNotificationContext; +}; + +export type AchievementToCheck = { + userId: string; + id: number; + achievedAt: Date; + context: Prisma.JsonValue; + template: Achievement_template; +}; + +export type EvaluationResult = { + conditionIsMet: boolean; + resultObject: Record; +}; + +export type RelationContextType = { + match?: { + id: number; + lecture: { + start: Date; + duration: number; + }; + }; + subcourse?: { + id: number; + lecture: { + start: Date; + duration: number; + }; + }; +}; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 92d845408..7cac21dd2 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -1,28 +1,48 @@ -import { ActionID, NotificationContext } from '../notification/types'; +import { ActionID } from '../notification/types'; import { metricsByAction } from './metrics'; -import { Metric } from './types'; - -export function getRelationByContext(context: NotificationContext): string { - const { appointment, match, subcourse } = context; - let relation: string; - - switch (context) { - case context.match: - relation = `match/${match.id}`; - break; - case context.subcourse: - relation = `subcourse/${subcourse.id}`; - break; - case context.appointment: - relation = `appointment/${appointment.id}`; - break; - default: - relation = ''; +import { Metric, RelationContextType } from './types'; +import { prisma } from '../prisma'; +import { getLogger } from '../logger/logger'; + +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 relation; + return isActive; } export function getMetricsByAction(actionId: ID): Metric[] { return metricsByAction.get(actionId) || []; } + +type RelationTypes = 'match' | 'subcourse'; + +export function getRelationTypeAndId(relation: string): [type: RelationTypes, id: number] { + const validRelationTypes = ['match', 'subcourse']; + const [relationType, relationId] = relation.split('/'); + if (!validRelationTypes.includes(relationType)) { + throw Error('No valid relation found in relation: ' + relationType); + } + + const parsedRelationId = parseInt(relationId, 10); + return [relationType as RelationTypes, parsedRelationId]; +} + +export async function getRelationContext(relation: string): Promise { + const [type, id] = getRelationTypeAndId(relation); + const relationContext: RelationContextType = { + match: + type === 'match' + ? await prisma.match.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } })[0] + : null, + subcourse: + type === 'subcourse' + ? await prisma.subcourse.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } })[0] + : null, + }; + return relationContext; +} diff --git a/common/notification/actions.ts b/common/notification/actions.ts index c47d62ebd..7f44e8744 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -614,6 +614,28 @@ const _notificationActions = { description: 'Screener suggests a Ressource for a User', sampleContext: {}, }, + + /* MEETINGS */ + student_joined_meeting: { + description: 'Student joined meeting', + sampleContext: {}, + }, + joined_meeting: { + description: 'User joined meeting', + sampleContext: {}, + }, + joined_match_meeting: { + description: 'User joined a match meeting', + sampleContext: { + matchId: '1', + }, + }, + joined_subcourse_meeting: { + description: 'User joined subcourse meeting', + sampleContext: { + subcourseId: '1', + }, + }, TEST: { description: 'For Tests', sampleContext: { a: 'a' }, diff --git a/common/notification/types.ts b/common/notification/types.ts index 5e25ba3a9..81425f765 100644 --- a/common/notification/types.ts +++ b/common/notification/types.ts @@ -34,6 +34,8 @@ export interface NotificationContextExtensions { campaign?: string; // For Campaigns, support sending custom Mailjet Notifications: overrideMailjetTemplateID?: string; + // TODO: For achievements + relationId?: string; } export interface NotificationContext extends NotificationContextExtensions { diff --git a/jobs/index.ts b/jobs/index.ts index 9650dd471..0a1fff874 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -12,6 +12,7 @@ import { executeJob } from './manualExecution'; // Ensure Notification hooks are always loaded import './../common/notification/hooks'; +import { registerAchievementMetrics } from '../common/achievement/metric'; //SETUP: logger const log = getLogger(); @@ -21,6 +22,9 @@ log.info('Backend started'); moment.locale('de'); //set global moment date format moment.tz.setDefault('Europe/Berlin'); //set global timezone (which is then used also for cron job scheduling and moment.format calls) +// SETUP: Metrics registration +registerAchievementMetrics(); + //SETUP: schedule jobs //SETUP: Add a graceful shutdown to the scheduler used diff --git a/package-lock.json b/package-lock.json index 62bc5813b..ec6d0989e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.369.0", "@aws-sdk/s3-request-presigner": "^3.137.0", + "@onlabsorg/swan-js": "^0.13.0", "@prisma/client": "4.15.0", "apollo-server-express": "3.4.0", "apollo-server-plugin-response-cache": "3.4.0", @@ -3702,6 +3703,27 @@ "node": ">= 8" } }, + "node_modules/@onlabsorg/swan-js": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@onlabsorg/swan-js/-/swan-js-0.13.0.tgz", + "integrity": "sha512-yx8STFeCxwy0wWRgTw0Jqu0ThjyzY6ovk9J7a/xyd4/RmdnI5y7QNqA7JkRZrmnLfrtAoTAVT5CtC3n7SMQJUA==", + "dependencies": { + "commander": "^8.3.0", + "isomorphic-fetch": "^3.0.0", + "path-browserify": "^1.0.1" + }, + "bin": { + "swan": "bin/swan" + } + }, + "node_modules/@onlabsorg/swan-js/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, "node_modules/@opentelemetry/api": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", @@ -10336,6 +10358,15 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -12712,8 +12743,7 @@ "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" }, "node_modules/path-exists": { "version": "4.0.0", @@ -15167,6 +15197,11 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.19", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.19.tgz", + "integrity": "sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index 33c891604..f845c2860 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.369.0", "@aws-sdk/s3-request-presigner": "^3.137.0", + "@onlabsorg/swan-js": "^0.13.0", "@prisma/client": "4.15.0", "apollo-server-express": "3.4.0", "apollo-server-plugin-response-cache": "3.4.0", diff --git a/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql b/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql new file mode 100644 index 000000000..0040a2ad0 --- /dev/null +++ b/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `achievedImage` to the `achievement_template` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "user_achievement_group_key"; + +-- AlterTable +ALTER TABLE "achievement_template" ADD COLUMN "achievedImage" VARCHAR NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 16cd2401b..8533ca454 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,7 @@ model achievement_template { subtitle String @db.VarChar description String @db.VarChar image String @db.VarChar + achievedImage String @db.VarChar // some achievements show actions that a user can/must perform next. // therefore we need the action name, type (for the icon) and a redirect link actionName String? @db.VarChar @@ -75,7 +76,7 @@ model user_achievement { // the description of group and groupOrder can be found in the achievement_template. // It needs to be stored in user_achievement, as the group contains templating. // The correctly assigned achievement for a match / a course is therefore stored in the user_achievement. - group String @unique @db.VarChar + group String @db.VarChar groupOrder Int isSeen Boolean @default(false) // achievedAt == null => not achieved, achievedAt != null => achieved diff --git a/seed-db.ts b/seed-db.ts index 3fc5caaff..d658cc7a9 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -26,6 +26,7 @@ import { course_subject_enum as CourseSubject, lecture_appointmenttype_enum as AppointmentType, } from '@prisma/client'; +import { achievement_action_type_enum, achievement_template_for_enum, achievement_type_enum } from './graphql/generated'; const logger = getLogger('DevSetup'); diff --git a/types/custom.d.ts b/types/custom.d.ts new file mode 100644 index 000000000..cb9b86a48 --- /dev/null +++ b/types/custom.d.ts @@ -0,0 +1,4 @@ +declare module '@onlabsorg/swan-js' { + declare function parse(condition: any): (context: any) => boolean; + export { parse }; +} diff --git a/web/index.ts b/web/index.ts index 2363dd758..f64501d6e 100644 --- a/web/index.ts +++ b/web/index.ts @@ -6,12 +6,14 @@ import { isCommandArg } from '../common/util/basic'; // Ensure Notification hooks are always loaded import './../common/notification/hooks'; +import { registerAchievementMetrics } from '../common/achievement/metric'; const logger = getLogger('WebServer'); logger.debug('Debug logging enabled'); moment.locale('de'); //set global moment date format moment.tz.setDefault('Europe/Berlin'); //set global timezone (which is then used also for cron job scheduling and moment.format calls) +registerAchievementMetrics(); export const started = (async function main() { logger.info(`Starting the Webserver`); From c59b3b934f763ff726192577b45ddbc7d1dfd204 Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:23:14 +0100 Subject: [PATCH 06/58] feat: achievements resolver (#903) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map * add first bucket creators * add package swan * add relation resolution * add first draft to evaluate * add function to check for achievements * add achievements by group * add create user achievement * feat: backend implementation achievement resolver * adjusted evaluation * some drafts * adjust create sequential user achievement * add feature flag * add test actions and mutations * add achieved image to db * adjust next step index * fix: change requests * fix awarding user * seed db example type swan js parser * feat: evaluation of tiered achievements (#910) feat: filter bucket with achievement creation * feat: evaluation of streaks (#909) * feat: filter bucket with achievement creation * fix: branch update * fix: create time buckets returning buckets * feat: streak evaluation * fix: update streak with correct values * fix: merge clearing * fix: removed unnecessary checks and added comments * fix inject record value * fix: make it work * fix: change index generation for state handling * fix: work in change requests * fix: add values to context * fix: safe empty json in db * fix: set right achievement state * fix: change requests * fix: requested changes * fix: add condition for relation Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: remove test data, change requests * fix: reset achievedAt when condition not met * feat: refactor of aggregators (#926) * refactor of aggregators * review changes * rename streak aggregator * feat: relation context (#911) * feat: save and get by relation match and subcourse * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: update AchievementContext * fix: adjustment of Achievement Context * fix: introduce helper functions, refactor context * fix: type duplication * fix: reflect metadata --------- Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> Co-authored-by: LomyW * feat: achievement is seen (#925) * feat: mutation for seen achievements * fix: change requests * fix: no await to get user achievements Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * feat: resolver values (#928) * feat: save and get by relation match and subcourse * add templates to seed db * fix: get next template on index * add metric and actions for student onboarding * trigger onboarding actions * - add pupil onboarding, conducted match meetings - student conducted match meetings * fix: create achievement next step index * add metrics and action for pupil onboarding * add achievement mutation resolver * extend meeting action context * add data for regular learned streak * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: update AchievementContext * fix: adjustment of Achievement Context * fix bucket by lecture start * prevent more events for one bucket * fix relation for buckets * fix bucket * get user achievement for create next * fix: introduce helper functions, refactor context * adjust bucketCreator for regular learned to weekly * fix: type duplication * fix: reflect metadata * fix: reflect metadata * refactor of aggregators * review changes * rename streak aggregator * adjust streak data * sort desc * feat: evaluation in resolver * fix: add record value to evaluation * fix: await achievements in resolver * fix: change requests --------- Co-authored-by: LomyW Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: changes requested * move recordValue to evaluate (#929) * fix: break and change description to name --------- Co-authored-by: LomyW Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> --- common/achievement/aggregator.ts | 1 + common/achievement/bucket.ts | 27 +- common/achievement/create.ts | 51 ++- common/achievement/evaluate.ts | 60 ++-- common/achievement/get.ts | 106 +++++++ common/achievement/helper.ts | 24 -- common/achievement/index.ts | 45 ++- common/achievement/metric.ts | 38 ++- common/achievement/metrics.ts | 1 + common/achievement/template.ts | 12 +- common/achievement/types.ts | 31 +- common/achievement/util.ts | 69 +++- common/notification/actions.ts | 51 ++- common/notification/index.ts | 2 +- common/secret/token.ts | 2 + graphql/achievement/mutations.ts | 45 +++ graphql/authorizations.ts | 2 +- graphql/index.ts | 3 + graphql/types/achievement.ts | 74 +++++ graphql/user/fields.ts | 22 +- graphql/user/mutations.ts | 10 + jobs/index.ts | 1 + seed-db.ts | 529 +++++++++++++++++++++++++++++++ utils/helpers.ts | 3 +- 24 files changed, 1051 insertions(+), 158 deletions(-) create mode 100644 common/achievement/get.ts delete mode 100644 common/achievement/helper.ts create mode 100644 graphql/achievement/mutations.ts create mode 100644 graphql/types/achievement.ts diff --git a/common/achievement/aggregator.ts b/common/achievement/aggregator.ts index 3ea74d1c3..478d546e3 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -23,6 +23,7 @@ export const aggregators: Aggregator = { }, lastStreakLength: { function: (elements): number => { + // elements are sorted desc, i.e. [KW 52, KW 51, KW 50] let value = 0; for (const element of elements) { if (element === 0) { diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 596b66924..93a29d76e 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,6 +1,6 @@ import moment from 'moment'; import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket } from './types'; -import { getRelationContext } from './util'; +import { getBucketContext, getRelationTypeAndId } from './util'; type BucketCreatorDefs = Record; @@ -12,17 +12,19 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, }, by_lecture_start: { - function: async (relation: string): Promise> => { + function: async (relation): Promise> => { + const [type] = getRelationTypeAndId(relation); + if (!relation) { return { bucketKind: 'time', buckets: [] }; } - const context = await getRelationContext(relation); - if (!context[relation].lecture) { + const context = await getBucketContext(relation); + if (!context[context.type].lecture) { return { bucketKind: 'time', buckets: [] }; } return { bucketKind: 'time', - buckets: context[relation].lecture.map((lecture) => ({ + buckets: context[context.type].lecture.map((lecture) => ({ kind: 'time', startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), @@ -31,13 +33,12 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, }, by_weeks: { - function: async (): Promise> => { - // TODO - where did we get the number of weeks - const weeks = 5; + function: async (_relation, weeks): Promise> => { + // the buckets are created in a desc order const today = moment(); const buckets: TimeBucket[] = []; - for (let i = 0; i < weeks; i++) { + for (let i = 0; i < weeks + 1; i++) { const weeksBefore = today.clone().subtract(i, 'week'); buckets.push({ kind: 'time', @@ -53,14 +54,12 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, }, by_months: { - function: async (): Promise> => { - // TODO - where did we get the number of months - - const months = 12; + function: async (_relation, months): Promise> => { + // the buckets are created in a desc order const today = moment(); const buckets: TimeBucket[] = []; - for (let i = 0; i < months; i++) { + for (let i = 0; i < months + 1; i++) { const monthsBefore = today.clone().subtract(i, 'month'); buckets.push({ kind: 'time', diff --git a/common/achievement/create.ts b/common/achievement/create.ts index c145f31b7..b3a4c1b86 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,47 +1,74 @@ import { Prisma } from '@prisma/client'; -import { Achievement_template } from '../../graphql/generated'; +import { Achievement_template, JsonFilter } from '../../graphql/generated'; +import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; -import { UserAchievementContext } from './types'; -async function doesUserAchievementAlreadyExist(templateId: number, userId: string) { - // TODO - check if user achievement exist for one match or one subcourse +async function findUserAchievement(templateId: number, userId: string, context: SpecificNotificationContext) { + const keys = Object.keys(context); const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, userId, + AND: keys.map((key) => { + return { + context: { + path: key, + equals: context[key], + }, + }; + }), }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); return userAchievement; } -async function getOrCreateUserAchievement(template: Achievement_template, userId: string, context: UserAchievementContext) { - const existingUserAchievement = await doesUserAchievementAlreadyExist(template.id, userId); +async function getOrCreateUserAchievement(template: Achievement_template, userId: string, context?: SpecificNotificationContext) { + const existingUserAchievement = await findUserAchievement(template.id, userId, context); if (!existingUserAchievement) { return await createAchievement(template, userId, context); } return existingUserAchievement; } -async function createAchievement(templateToCreate: Achievement_template, userId: string, context: UserAchievementContext) { +async function createAchievement(templateToCreate: Achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + const keys = Object.keys(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ - where: { template: { group: templateToCreate.group } }, + where: { + template: { + group: templateToCreate.group, + }, + userId, + AND: keys.map((key) => { + return { + context: { + path: key, + equals: context[key], + }, + }; + }), + }, orderBy: { template: { groupOrder: 'asc' } }, }); - const nextStepIndex = userAchievementsByGroup.length > 0 ? templateToCreate.groupOrder + 1 : 1; + const nextStepIndex = userAchievementsByGroup.length > 0 ? userAchievementsByGroup.findIndex((e) => e.groupOrder === templateToCreate.groupOrder) + 1 : 0; const templatesForGroup = templatesByGroup.get(templateToCreate.group); - if (templatesForGroup && templatesForGroup.length >= nextStepIndex) { + if (templatesForGroup && templatesForGroup.length > nextStepIndex) { const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); return createdUserAchievement; } } -async function createNextUserAchievement(templatesForGroup: Achievement_template[], nextStepIndex: number, userId: string, context: UserAchievementContext) { - const nextStepTemplate = templatesForGroup.find((template) => template.groupOrder === nextStepIndex); +async function createNextUserAchievement( + templatesForGroup: Achievement_template[], + nextStepIndex: number, + userId: string, + context: SpecificNotificationContext +) { + 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) { diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 74f6a1851..9c4ab710f 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -1,14 +1,21 @@ import { Achievement_event } from '../../graphql/generated'; -import { BucketConfig, BucketEvents, BucketEventsWithAggr, ConditionDataAggregations, EvaluationResult } from './types'; +import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; import { prisma } from '../prisma'; import { aggregators } from './aggregator'; import swan from '@onlabsorg/swan-js'; import { bucketCreatorDefs } from './bucket'; - -export async function evaluateAchievement(condition: string, dataAggregation: ConditionDataAggregations, metrics: string[]): Promise { - const achievementEvents = await prisma.achievement_event.findMany({ where: { metric: { in: metrics } } }); - - const eventsByMetric: Record = {}; // Store events per metric +import { getLogger } from '../logger/logger'; +const logger = getLogger('Achievement'); + +export async function evaluateAchievement( + condition: string, + dataAggregation: ConditionDataAggregations, + metrics: string[], + recordValue: number +): Promise { + const achievementEvents = await prisma.achievement_event.findMany({ where: { metric: { in: metrics } }, orderBy: { createdAt: 'desc' } }); + + const eventsByMetric: Record = {}; for (const event of achievementEvents) { if (!eventsByMetric[event.metric]) { eventsByMetric[event.metric] = []; @@ -17,10 +24,11 @@ export async function evaluateAchievement(condition: string, dataAggregation: Co } const resultObject: Record = {}; + resultObject['recordValue'] = recordValue; for (const key in dataAggregation) { if (!dataAggregation[key]) { - return; + continue; } const dataAggregationObject = dataAggregation[key]; const metricName = dataAggregationObject.metric; @@ -32,7 +40,7 @@ export async function evaluateAchievement(condition: string, dataAggregation: Co const eventsForMetric = eventsByMetric[metricName]; if (!eventsForMetric) { - return; + 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; @@ -42,22 +50,18 @@ export async function evaluateAchievement(condition: string, dataAggregation: Co const aggFunction = aggregators[aggregator].function; if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggFunction) { + logger.error( + `No bucket creator or aggregator function found for ${bucketCreator}, ${aggregator} or ${bucketAggregator} during the evaluation of achievement` + ); return; } - const buckets = await bucketCreatorFunction(relation); + const buckets = await bucketCreatorFunction(relation, recordValue); const bucketEvents = createBucketEvents(eventsForMetric, buckets); - const bucketAggr = bucketEvents.map( - (bucketEvent): BucketEventsWithAggr => ({ - ...bucketEvent, - aggregation: bucketAggregatorFunction(bucketEvent.events.map((event) => event.value)), - }) - ); - - const valuesFromBucketAggr = bucketAggr.map((bucket) => bucket.aggregation); + const bucketAggr = bucketEvents.map((bucketEvent) => bucketAggregatorFunction(bucketEvent.events.map((event) => event.value))); - const value = aggFunction(valuesFromBucketAggr); + const value = aggFunction(bucketAggr); resultObject[key] = value; } @@ -76,8 +80,6 @@ export function createBucketEvents(events: Achievement_event[], bucketConfig: Bu return createDefaultBuckets(events, bucketConfig); case 'time': return createTimeBuckets(events, bucketConfig); - case 'filter': - return createFilterBuckets(events, bucketConfig); } } @@ -91,9 +93,9 @@ const createDefaultBuckets = (events: Achievement_event[], bucketConfig: BucketC const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { const { buckets } = bucketConfig; const bucketsWithEvents: BucketEvents[] = buckets.map((bucket) => { - const filteredEvents = events.filter((event) => { - return event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime; - }); + // values will be sorted in a desc order + const filteredEvents = events.filter((event) => event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime); + return { kind: bucket.kind, startTime: bucket.startTime, @@ -103,15 +105,3 @@ const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConf }); return bucketsWithEvents; }; - -const createFilterBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { - const { buckets } = bucketConfig; - const filteredEvents = events.filter((event) => { - return buckets.some((bucket) => bucket.actionName === event.action); - }); - return filteredEvents.map((event) => ({ - kind: 'filter', - actionName: event.action, - events: [event], - })); -}; diff --git a/common/achievement/get.ts b/common/achievement/get.ts new file mode 100644 index 000000000..1e22dcec7 --- /dev/null +++ b/common/achievement/get.ts @@ -0,0 +1,106 @@ +import { prisma } from '../prisma'; +import { User_achievement, achievement_action_type_enum, achievement_type_enum } from '../../graphql/generated'; +import { Achievement, achievement_state } from '../../graphql/types/achievement'; +import { User } from '../user'; +import { ConditionDataAggregations } from './types'; +import { getAchievementState, getCurrentAchievementTemplateWithContext, transformPrismaJson } from './util'; +import { evaluateAchievement } from './evaluate'; + +const getUserAchievements = async (user: User): Promise => { + const userAchievements = await prisma.user_achievement.findMany({ + where: { userId: user.userID }, + include: { template: true }, + }); + const userAchievementGroups: { [group: string]: User_achievement[] } = {}; + userAchievements.forEach((ua) => { + if (!userAchievementGroups[ua.template.group]) { + userAchievementGroups[ua.template.group] = []; + } + userAchievementGroups[ua.template.group].push(ua); + }); + const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); + return achievements; +}; + +const generateReorderedAchievementData = async (groups: { [group: string]: User_achievement[] }, user: User): Promise => { + const achievements: Achievement[] = []; + for (const group in groups) { + const sortedGroupAchievements = groups[group].sort((a, b) => a.groupOrder - b.groupOrder); + if (sortedGroupAchievements[0].template.type === achievement_type_enum.TIERED) { + sortedGroupAchievements.forEach(async (groupAchievement, index) => { + const achievement: Achievement = await assembleAchievementData([groupAchievement], user); + achievements.push(achievement); + }); + } else { + const groupAchievement: Achievement = await assembleAchievementData(sortedGroupAchievements, user); + achievements.push(groupAchievement); + } + } + return Promise.all(achievements); +}; + +const assembleAchievementData = async (userAchievements: User_achievement[], user: User): Promise => { + let currentAchievementIndex = userAchievements.findIndex((ua) => !ua.achievedAt); + currentAchievementIndex = currentAchievementIndex >= 0 ? currentAchievementIndex : userAchievements.length - 1; + + const achievementContext = await transformPrismaJson(user, userAchievements[currentAchievementIndex].context); + const currentAchievementTemplate = getCurrentAchievementTemplateWithContext(userAchievements[currentAchievementIndex], achievementContext); + + const state: achievement_state = getAchievementState(userAchievements, currentAchievementIndex); + + const isNewAchievement = state === achievement_state.COMPLETED && !userAchievements[currentAchievementIndex].isSeen; + + const condition = currentAchievementTemplate.condition.includes('recordValue') + ? currentAchievementTemplate.condition.replace('recordValue', (userAchievements[currentAchievementIndex].recordValue + 1).toString()) + : currentAchievementTemplate.condition; + + let maxValue; + let currentValue; + if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { + const dataAggregationKey = Object.keys(currentAchievementTemplate.conditionDataAggregations)[0]; + const evaluationResult = await evaluateAchievement( + condition, + currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, + currentAchievementTemplate.metrics, + userAchievements[currentAchievementIndex].recordValue + ); + currentValue = evaluationResult.resultObject[dataAggregationKey]; + maxValue = + currentAchievementTemplate.type === achievement_type_enum.STREAK + ? userAchievements[currentAchievementIndex].recordValue + : currentAchievementTemplate.conditionDataAggregations[dataAggregationKey].valueToAchieve || 0; + } else { + currentValue = currentAchievementIndex + 1; + maxValue = userAchievements.length - 1; + } + + return { + id: userAchievements[currentAchievementIndex].id, + name: currentAchievementTemplate.name, + subtitle: currentAchievementTemplate.subtitle, + description: currentAchievementTemplate.description, + image: currentAchievementTemplate.image, + alternativeText: 'alternativeText', + actionType: currentAchievementTemplate.actionType as achievement_action_type_enum, + achievementType: currentAchievementTemplate.type as achievement_type_enum, + achievementState: state, + steps: currentAchievementTemplate.stepName + ? userAchievements.map((achievement, index) => { + // if a achievementTemplate has a stepName, it means that it must have multiple steps as well as being a sequential achievement + // for every achievement in the sortedGroupAchievements, we create a step object with the stepName (descirption) and isActive property for the achievement step currently active but unachieved + return { + name: achievement.template.stepName, + isActive: index === currentAchievementIndex, + }; + }) + : null, + maxSteps: maxValue, + currentStep: currentValue, + isNewAchievement: isNewAchievement, + progressDescription: `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, + actionName: userAchievements[currentAchievementIndex].template.actionName, + actionRedirectLink: userAchievements[currentAchievementIndex].template.actionRedirectLink, + }; +}; + +export { getUserAchievements }; diff --git a/common/achievement/helper.ts b/common/achievement/helper.ts deleted file mode 100644 index 026bebfd3..000000000 --- a/common/achievement/helper.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Achievement_template } from '../../graphql/generated'; - -export function sortActionTemplatesToGroups(templatesForAction: Achievement_template[]) { - const templatesByGroups: Map = new Map(); - for (const template of templatesForAction) { - if (!templatesByGroups.has(template.group)) { - templatesByGroups.set(template.group, []); - } - templatesByGroups.get(template.group).push(template); - } - templatesByGroups.forEach((group, key) => { - group.sort((a, b) => a.groupOrder - b.groupOrder); - templatesByGroups.set(key, group); - }); - return templatesByGroups; -} - -// replace recordValue in condition with number of last record -export function injectRecordValue(condition: string, recordValue: number) { - if (typeof recordValue === 'number') { - return condition.replace('recordValue', recordValue.toString()); - } - return condition; -} diff --git a/common/achievement/index.ts b/common/achievement/index.ts index eaff56fa5..13a53be89 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -1,19 +1,16 @@ import { prisma } from '../prisma'; import { User } from '../user'; -import { isGamificationFeatureActive, getMetricsByAction } from './util'; +import { isGamificationFeatureActive, getMetricsByAction, sortActionTemplatesToGroups } from './util'; import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { getTemplatesByAction } 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'; -import { Achievement_template } from '../../graphql/generated'; -import { Prisma } from '@prisma/client'; -import { injectRecordValue, sortActionTemplatesToGroups } from './helper'; const logger = getLogger('Achievement'); -export async function actionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { +export async function rewardActionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { if (!isGamificationFeatureActive()) { return; } @@ -26,17 +23,16 @@ export async function actionTaken(user: User, actionId: ID, const templatesByGroups = sortActionTemplatesToGroups(templatesForAction); - const event: ActionEvent = { + const actionEvent: ActionEvent = { actionId, at: new Date(), user: user, context, }; - await trackEvent(event, context); + await trackEvent(actionEvent); - for (const [key, group] of templatesByGroups) { + for (const [, group] of templatesByGroups) { let achievementToCheck: AchievementToCheck; - const context = {} as UserAchievementContext; for (const template of group) { const userAchievement = await getOrCreateUserAchievement(template, user.userID, context); if (userAchievement.achievedAt === null || userAchievement.recordValue) { @@ -45,14 +41,12 @@ export async function actionTaken(user: User, actionId: ID, } } if (achievementToCheck) { - await checkUserAchievement(achievementToCheck as UserAchievementTemplate); + await checkUserAchievement(achievementToCheck as UserAchievementTemplate, context); } } - - return; } -async function trackEvent(event: ActionEvent, context: SpecificNotificationContext) { +async function trackEvent(event: ActionEvent) { const metricsForEvent = getMetricsByAction(event.actionId); if (!metricsForEvent) { @@ -62,7 +56,7 @@ async function trackEvent(event: ActionEvent, context: for (const metric of metricsForEvent) { const formula = metric.formula; - const value = formula(context); + const value = formula(event.context); await prisma.achievement_event.create({ data: { @@ -70,7 +64,6 @@ async function trackEvent(event: ActionEvent, context: value: value, action: event.actionId, userId: event.user.userID, - // TODO - get relation OR get relationId from context relation: event.context.relationId ?? '', }, }); @@ -79,29 +72,35 @@ async function trackEvent(event: ActionEvent, context: return true; } -async function checkUserAchievement(userAchievement: UserAchievementTemplate) { +async function checkUserAchievement(userAchievement: UserAchievementTemplate | undefined, context: SpecificNotificationContext) { const evaluationResult = await isAchievementConditionMet(userAchievement); + if (evaluationResult.conditionIsMet) { - const dataAggregationKey = Object.keys(userAchievement.template.conditionDataAggregations as ConditionDataAggregations)[0]; + const conditionDataAggregations = userAchievement?.template.conditionDataAggregations as ConditionDataAggregations; + const dataAggregationKey = Object.keys(conditionDataAggregations)[0]; const evaluationResultValue = typeof evaluationResult.resultObject[dataAggregationKey] === 'number' ? Number(evaluationResult.resultObject[dataAggregationKey]) : null; + const awardedAchievement = await awardUser(evaluationResultValue, userAchievement); - const userAchievementContext: UserAchievementContext = {}; - await createAchievement(awardedAchievement.template, userAchievement.userId, userAchievementContext); + await createAchievement(awardedAchievement.template, userAchievement.userId, context); + } else { + await prisma.user_achievement.update({ + where: { id: userAchievement.id }, + data: { achievedAt: null, isSeen: false }, + }); } } async function isAchievementConditionMet(achievement: UserAchievementTemplate) { const { - userId, + recordValue, template: { condition, conditionDataAggregations, metrics }, } = achievement; if (!condition) { return; } - const updatedCondition = injectRecordValue(condition, achievement.recordValue); - const { conditionIsMet, resultObject } = await evaluateAchievement(updatedCondition, conditionDataAggregations as ConditionDataAggregations, metrics); + const { conditionIsMet, resultObject } = await evaluateAchievement(condition, conditionDataAggregations as ConditionDataAggregations, metrics, recordValue); return { conditionIsMet, resultObject }; } diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index 9ee9d1e77..2227b3c60 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -22,21 +22,45 @@ function createMetric>(metr } const batchOfMetrics = [ - createMetric('onboarding_verified_email', ['user_registration_verified_email'], () => { + /* STUDENT ONBOARDING */ + createMetric('student_onboarding_verified', ['student_registration_verified_email'], () => { return 1; }), - createMetric('onboarding_screening_events', ['tutor_screening_success', 'instructor_screening_success'], () => { + // TODO - relevant if calendly API is integrated + createMetric('student_onboarding_appointment_booked', ['student_calendly_appointment_booked'], () => { return 1; }), - createMetric('onboarding_coc_event', ['student_coc_updated'], () => { + createMetric('student_onboarding_screened', ['student_screening_appointment_done', 'tutor_screening_success', 'instructor_screening_success'], () => { return 1; }), - // student_conducted_match_appointment is a tiered achievement and therefore needs to be initialized, in this case with tutor_match_requested - // later requests to this metric will onle be conducted by joined_match_meeting. This is secured by the filter bucket - createMetric('student_conducted_match_appointment', ['tutor_match_requested', 'joined_match_meeting'], () => { + createMetric('student_onboarding_coc_success', ['student_coc_updated'], () => { return 1; }), - createMetric('weekly_presence', ['joined_meeting'], () => { + /* PUPIL ONBOARDING */ + createMetric('pupil_onboarding_verified', ['pupil_registration_verified_email'], () => { + return 1; + }), + // TODO - relevant if calendly API is integrated + createMetric('pupil_onboarding_appointment_booked', ['pupil_calendly_appointment_booked'], () => { + return 1; + }), + createMetric('pupil_onboarding_screened', ['pupil_screening_appointment_done', 'pupil_screening_add', 'pupil_screening_succeeded'], () => { + return 1; + }), + + /* CONDUCTED MATCH APPOINTMENT */ + createMetric('student_conducted_match_appointment', ['student_joined_match_meeting'], () => { + return 1; + }), + createMetric('pupil_conducted_match_appointment', ['pupil_joined_match_meeting'], () => { + return 1; + }), + + /* REGULAR MATCH LEARNING */ + createMetric('pupil_match_learned_regular', ['pupil_joined_match_meeting'], () => { + return 1; + }), + createMetric('student_match_learned_regular', ['student_joined_match_meeting'], () => { return 1; }), ]; diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts index ddb630273..b80bd7f75 100644 --- a/common/achievement/metrics.ts +++ b/common/achievement/metrics.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import { ActionID } from '../notification/actions'; import { Metric } from './types'; diff --git a/common/achievement/template.ts b/common/achievement/template.ts index ff32d6790..f3555d3d6 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import { Achievement_template } from '../../graphql/generated'; import { getLogger } from '../logger/logger'; import { ActionID } from '../notification/actions'; @@ -54,11 +55,14 @@ async function getTemplatesByAction(actionId: ID) { const metricsForAction = metricsByAction.get(actionId); let templatesForAction: Achievement_template[] = []; - for (const metric of metricsForAction) { - templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; + if (!metricsForAction || !templatesByMetric) { + return []; + } else { + for (const metric of metricsForAction) { + templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; + } + return templatesForAction; } - - return templatesForAction; } async function doesTemplateExistForAction(actionId: ID): Promise { diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 98056480f..e30420e44 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client'; -import { Achievement_event, Achievement_template, achievement_type_enum } from '../../graphql/generated'; +import { Achievement_event, Achievement_template, Lecture } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { User } from '../user'; @@ -20,7 +20,7 @@ export type GenericBucketConfig = { buckets: T[]; }; // Combines all possible bucket configs -export type BucketConfig = GenericBucketConfig | GenericBucketConfig | GenericBucketConfig; +export type BucketConfig = GenericBucketConfig | GenericBucketConfig; export type DefaultBucket = { kind: 'default'; @@ -31,13 +31,8 @@ export type TimeBucket = { startTime: Date; endTime: Date; }; -// Bucket containing events that match a specific actionName -export type FilterBucket = { - kind: 'filter'; - actionName: string; -}; // A bucket is seen as for a period of time -export type Bucket = DefaultBucket | TimeBucket | FilterBucket; +export type Bucket = DefaultBucket | TimeBucket; export type BucketEvents = Bucket & { events: Achievement_event[]; @@ -46,7 +41,7 @@ export type BucketEventsWithAggr = BucketEvents & { aggregation: number; }; -type BucketFormulaFunction = (relation?: string) => Promise; +type BucketFormulaFunction = (relation?: string, numberOfPeriode?: number) => Promise; export type BucketFormula = { function: BucketFormulaFunction; @@ -103,19 +98,19 @@ export type EvaluationResult = { resultObject: Record; }; -export type RelationContextType = { +export type RelationTypes = 'match' | 'subcourse'; + +type ContextLecture = Pick; + +export type AchievementContextType = { + type: RelationTypes; + user?: User; match?: { id: number; - lecture: { - start: Date; - duration: number; - }; + lecture: ContextLecture[]; }; subcourse?: { id: number; - lecture: { - start: Date; - duration: number; - }; + lecture: ContextLecture[]; }; }; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 7cac21dd2..6b2db2219 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -1,8 +1,13 @@ import { ActionID } from '../notification/types'; import { metricsByAction } from './metrics'; -import { Metric, RelationContextType } from './types'; +import { Metric, 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 { @@ -19,8 +24,6 @@ export function getMetricsByAction(actionId: ID): Metric[] return metricsByAction.get(actionId) || []; } -type RelationTypes = 'match' | 'subcourse'; - export function getRelationTypeAndId(relation: string): [type: RelationTypes, id: number] { const validRelationTypes = ['match', 'subcourse']; const [relationType, relationId] = relation.split('/'); @@ -32,17 +35,67 @@ export function getRelationTypeAndId(relation: string): [type: RelationTypes, id return [relationType as RelationTypes, parsedRelationId]; } -export async function getRelationContext(relation: string): Promise { +export async function getBucketContext(relation: string): Promise { const [type, id] = getRelationTypeAndId(relation); - const relationContext: RelationContextType = { + const achievementContext: AchievementContextType = { + type: type, match: type === 'match' - ? await prisma.match.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } })[0] + ? await prisma.match.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } }) : null, subcourse: type === 'subcourse' - ? await prisma.subcourse.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } })[0] + ? await prisma.subcourse.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } }) : null, }; - return relationContext; + return achievementContext; +} + +export function transformPrismaJson(user: User, json: Prisma.JsonValue): AchievementContextType | null { + if (!json['match'] && !json['subcourse']) { + return null; + } + const transformedJson: AchievementContextType = { + type: json['match'] ? 'match' : 'subcourse', + user: user, + match: json['match'] ? json['match'] : undefined, + subcourse: json['subcourse'] ? json['subcourse'] : undefined, + }; + return transformedJson; +} + +export function getCurrentAchievementTemplateWithContext(userAchievement: User_achievement, achievementContext: AchievementContextType): Achievement_template { + const currentAchievementContext = userAchievement.template as Achievement_template; + const templateKeys = Object.keys(userAchievement.template); + templateKeys.forEach((key) => { + const updatedElement = + currentAchievementContext[key] && typeof currentAchievementContext[key] === 'string' + ? renderTemplate(currentAchievementContext[key], achievementContext) + : currentAchievementContext[key]; + currentAchievementContext[key] = updatedElement; + }); + return currentAchievementContext; +} + +export function getAchievementState(userAchievements: User_achievement[], currentAchievementIndex: number) { + return userAchievements.length === 0 + ? achievement_state.INACTIVE + : userAchievements[currentAchievementIndex].achievedAt + ? achievement_state.COMPLETED + : achievement_state.ACTIVE; +} + +export function sortActionTemplatesToGroups(templatesForAction: Achievement_template[]) { + const templatesByGroups: Map = new Map(); + for (const template of templatesForAction) { + if (!templatesByGroups.has(template.group)) { + templatesByGroups.set(template.group, []); + } + templatesByGroups.get(template.group).push(template); + } + templatesByGroups.forEach((group, key) => { + group.sort((a, b) => a.groupOrder - b.groupOrder); + templatesByGroups.set(key, group); + }); + return templatesByGroups; } diff --git a/common/notification/actions.ts b/common/notification/actions.ts index 7f44e8744..c89370477 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -615,23 +615,56 @@ const _notificationActions = { sampleContext: {}, }, - /* MEETINGS */ - student_joined_meeting: { - description: 'Student joined meeting', + // ACHIEVEMENT ACTIONS + + /* ONBOARDING */ + student_screening_appointment_done: { + description: 'Student has attended screening appointment', + sampleContext: {}, + }, + pupil_screening_appointment_done: { + description: 'Pupil has attended screening appointment', + sampleContext: {}, + }, + pupil_registration_verified_email: { + description: 'Pupil / E-Mail verified', + sampleContext: {}, + }, + student_registration_verified_email: { + description: 'Student / E-Mail verified', + sampleContext: {}, + }, + student_calendly_appointment_booked: { + description: 'Student booked appointment via calendly ', sampleContext: {}, }, - joined_meeting: { - description: 'User joined meeting', + pupil_calendly_appointment_booked: { + description: 'Pupil booked appointment via calendly ', sampleContext: {}, }, - joined_match_meeting: { - description: 'User joined a match meeting', + /* MEETINGS */ + student_joined_match_meeting: { + description: 'Student joined a match meeting', + sampleContext: { + matchId: '1', + pupil: { firstname: 'Pupil' }, // = matchpartner + relationId: 'match/1', + }, + }, + student_joined_subcourse_meeting: { + description: 'Student joined subcourse meeting', + sampleContext: { + subcourseId: '1', + }, + }, + pupil_joined_match_meeting: { + description: 'Pupil joined a match meeting', sampleContext: { matchId: '1', }, }, - joined_subcourse_meeting: { - description: 'User joined subcourse meeting', + pupil_joined_subcourse_meeting: { + description: 'Pupil joined subcourse meeting', sampleContext: { subcourseId: '1', }, diff --git a/common/notification/index.ts b/common/notification/index.ts index 2084ea728..285e16585 100644 --- a/common/notification/index.ts +++ b/common/notification/index.ts @@ -402,7 +402,7 @@ export async function actionTaken( return; } - await Achievement.actionTaken(user, actionId, notificationContext); + await Achievement.rewardActionTaken(user, actionId, notificationContext); return await actionTakenAt(new Date(), user, actionId, notificationContext, false, noDuplicates, attachments); } diff --git a/common/secret/token.ts b/common/secret/token.ts index 983999932..5175a471c 100644 --- a/common/secret/token.ts +++ b/common/secret/token.ts @@ -168,6 +168,7 @@ export async function verifyEmail(user: User) { data: { verifiedAt: new Date(), verification: null }, where: { id: user.studentId }, }); + await Notification.actionTaken(user, 'student_registration_verified_email', {}); logger.info(`Student(${user.studentId}) verified their e-mail by logging in with an e-mail token`); } @@ -183,6 +184,7 @@ export async function verifyEmail(user: User) { data: { verifiedAt: new Date(), verification: null }, where: { id: user.pupilId }, }); + await Notification.actionTaken(user, 'pupil_registration_verified_email', {}); logger.info(`Pupil(${user.pupilId}) verified their e-mail by logging in with an e-mail token`); } diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts new file mode 100644 index 000000000..140398b23 --- /dev/null +++ b/graphql/achievement/mutations.ts @@ -0,0 +1,45 @@ +import { Arg, Authorized, Ctx, Mutation, Resolver } from 'type-graphql'; +import { User_achievement as Achievement } from '../generated'; +import { Role } from '../roles'; +import * as Notification from '../../common/notification'; +import { GraphQLContext } from '../context'; +import { AuthorizedDeferred, hasAccess } from '../authorizations'; +import { prisma } from '../../common/prisma'; + +@Resolver(() => Achievement) +export class MutateAchievementResolver { + @Mutation((returns) => Boolean) + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async matchMeetingJoin(@Ctx() context: GraphQLContext, @Arg('matchId') matchId: number) { + const { user } = context; + const match = await prisma.match.findUnique({ where: { id: matchId }, include: { pupil: true, student: true } }); + await hasAccess(context, 'Match', match); + + if (user.studentId) { + await Notification.actionTaken(user, 'student_joined_match_meeting', { + matchId: matchId.toString(), + pupil: match.pupil, + relationId: `match/${matchId}`, + }); + } else if (user.pupilId) { + await Notification.actionTaken(user, 'pupil_joined_match_meeting', { + matchId: matchId.toString(), + relationId: `match/${matchId}`, + }); + } + + 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/authorizations.ts b/graphql/authorizations.ts index 9da216e73..ea97f14d6 100644 --- a/graphql/authorizations.ts +++ b/graphql/authorizations.ts @@ -342,7 +342,7 @@ export const authorizationEnhanceMap: Required = { Pupil_screening: allAdmin, Waiting_list_enrollment: allAdmin, Achievement_template: allAdmin, - User_achievement: allAdmin, // TODO change + User_achievement: allAdmin, Achievement_event: allAdmin, }; diff --git a/graphql/index.ts b/graphql/index.ts index 99b402c17..f4331dee9 100644 --- a/graphql/index.ts +++ b/graphql/index.ts @@ -71,6 +71,7 @@ import { playground } from './playground'; import { ExtendedFieldsScreenerResolver } from './screener/fields'; import { ExtendedFieldsCooperationResolver } from './cooperation/fields'; import { MutateCooperationResolver } from './cooperation/mutation'; +import { MutateAchievementResolver } from './achievement/mutations'; applyResolversEnhanceMap(authorizationEnhanceMap); applyResolversEnhanceMap(complexityEnhanceMap); @@ -184,6 +185,8 @@ const schema = buildSchemaSync({ ExtendedFieldsCooperationResolver, MutateCooperationResolver, + /* Achievement */ + MutateAchievementResolver, AdminMutationsResolver, ], authChecker, diff --git a/graphql/types/achievement.ts b/graphql/types/achievement.ts new file mode 100644 index 000000000..49d993858 --- /dev/null +++ b/graphql/types/achievement.ts @@ -0,0 +1,74 @@ +import { ObjectType, Field, Int, registerEnumType } from 'type-graphql'; +import { achievement_action_type_enum, achievement_type_enum } from '../generated'; + +enum achievement_state { + INACTIVE = 'INACTIVE', + ACTIVE = 'ACTIVE', + COMPLETED = 'COMPLETED', +} + +registerEnumType(achievement_state, { + name: 'achievement_state', +}); + +@ObjectType() +class Achievement { + @Field() + id: number; + + @Field() + name: string; + + @Field() + subtitle: string; + + @Field() + description: string; + + @Field() + image: string; + + @Field() + alternativeText: string; + + @Field(() => achievement_action_type_enum, { nullable: true }) + actionType: achievement_action_type_enum; + + @Field(() => achievement_type_enum) + achievementType: achievement_type_enum; + + @Field(() => achievement_state) + achievementState: achievement_state; + + @Field(() => [Step], { nullable: true }) + steps?: Step[]; + + @Field(() => Int) + maxSteps: number; + + @Field(() => Int) + currentStep?: number; + + @Field({ nullable: true }) + isNewAchievement?: boolean; + + @Field({ nullable: true }) + progressDescription?: string; + + @Field({ nullable: true }) + actionName?: string; + + @Field({ nullable: true }) + actionRedirectLink?: string; +} + +@ObjectType() +class Step { + @Field() + name: string; + + @Field({ nullable: true }) + isActive?: boolean; +} + +export { Achievement, Step, achievement_state }; diff --git a/graphql/user/fields.ts b/graphql/user/fields.ts index 4d2e0fd31..f12cd5226 100644 --- a/graphql/user/fields.ts +++ b/graphql/user/fields.ts @@ -1,4 +1,16 @@ -import { Student, Pupil, Screener, Secret, Concrete_notification as ConcreteNotification, Lecture, StudentWhereInput, PupilWhereInput } from '../generated'; +import { + Student, + Pupil, + Screener, + Secret, + Concrete_notification as ConcreteNotification, + Lecture, + StudentWhereInput, + PupilWhereInput, + Achievement_event, + User_achievement, + Achievement_template, +} from '../generated'; import { Root, Authorized, FieldResolver, Query, Resolver, Arg, Ctx, ObjectType, Field, Int } from 'type-graphql'; import { UNAUTHENTICATED_USER, loginAsUser } from '../authentication'; import { GraphQLContext } from '../context'; @@ -16,6 +28,8 @@ import { getMyContacts, UserContactType } from '../../common/chat/contacts'; import { generateMeetingSDKJWT, isZoomFeatureActive } from '../../common/zoom/util'; import { getUserZAK, getZoomUsers } from '../../common/zoom/user'; import { ConcreteNotificationState } from '../../common/notification/types'; +import { getUserAchievements } from '../../common/achievement/get'; +import { Achievement } from '../types/achievement'; @ObjectType() export class UserContact implements UserContactType { @@ -196,6 +210,12 @@ export class UserFieldsResolver { return await getAppointmentsForUser(user, take, skip, cursor, direction); } + @FieldResolver((returns) => [Achievement]) + @Authorized(Role.ADMIN, Role.OWNER) + achievements(@Ctx() context: GraphQLContext): Promise { + return getUserAchievements(context.user); + } + @FieldResolver((returns) => Boolean) @Authorized(Role.ADMIN, Role.OWNER) async hasAppointments(@Root() user: User): Promise { diff --git a/graphql/user/mutations.ts b/graphql/user/mutations.ts index 7d4d865ad..85e186017 100644 --- a/graphql/user/mutations.ts +++ b/graphql/user/mutations.ts @@ -12,6 +12,7 @@ import { Length } from 'class-validator'; import { validateEmail } from '../validators'; import { getLogger } from '../../common/logger/logger'; import { DEFAULTSENDERS, sendMail } from '../../common/notification/channels/mailjet'; +import { prisma } from '../../common/prisma'; @InputType() class SupportMessage { @@ -72,4 +73,13 @@ export class MutateUserResolver { return true; } + @Mutation(() => Boolean) + @Authorized(Role.USER) + async markAchievementAsSeen(@Arg('achievementId') achievementId: number) { + await prisma.user_achievement.update({ + where: { id: achievementId }, + data: { isSeen: true }, + }); + return true; + } } diff --git a/jobs/index.ts b/jobs/index.ts index 0a1fff874..b6abe0df8 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -29,6 +29,7 @@ registerAchievementMetrics(); //SETUP: Add a graceful shutdown to the scheduler used configureGracefulShutdown(scheduler); +registerAchievementMetrics(); // Manual job execution via npm run jobs -- --execute if (process.argv.length >= 4 && process.argv[2] === '--execute') { diff --git a/seed-db.ts b/seed-db.ts index d658cc7a9..cda55114e 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1,4 +1,5 @@ /* eslint-disable comma-dangle */ +import 'reflect-metadata'; import { createHash, randomBytes } from 'crypto'; import { hashPassword } from './common/util/hashing'; import { getNotifications, importMessageTranslations, importNotifications } from './common/notification/notification'; @@ -739,6 +740,534 @@ void (async function setupDevDB() { }, }); + /* Achievements */ + // STUDENT ONBOARDING + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_verified'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 1, + stepName: 'Verifizieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_00', + achievedImage: '', + actionName: 'E-Mail erneut senden', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Action, + condition: 'student_verified_events > 0', + conditionDataAggregations: { student_verified_events: { metric: 'student_onboarding_verified', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_appointment_booked'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 2, + stepName: 'Kennenlerngespräch buchen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_01', + achievedImage: '', + actionName: 'Termin vereinbaren', + actionRedirectLink: 'https://calendly.com', + actionType: achievement_action_type_enum.Action, + condition: 'student_appointment_booked_events > 0', + conditionDataAggregations: { + student_appointment_booked_events: { metric: 'student_onboarding_appointment_booked', aggregator: 'count' }, + }, + isActive: false, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 3, + stepName: 'Screening absolvieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Screening absolvieren', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Appointment, + condition: 'student_screened_events > 0', + conditionDataAggregations: { student_screened_events: { metric: 'student_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_coc_success'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 4, + stepName: 'Führungszeugnis einreichen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Zeugnis einreichen', + actionRedirectLink: 'mailto:fz@lern-fair.de', + actionType: achievement_action_type_enum.Action, + condition: 'student_coc_success_events > 0', + conditionDataAggregations: { student_coc_success_events: { metric: 'student_onboarding_coc_success', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_coc_success'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 5, + stepName: 'Onboarding abgeschlossen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Flugticket', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'student_coc_success_events > 0', + conditionDataAggregations: { student_coc_success_events: { metric: 'student_onboarding_coc_success', aggregator: 'count' } }, + isActive: true, + }, + }); + // PUPIL ONBOARDING + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_verified'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 1, + stepName: 'Verifizieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_00', + achievedImage: '', + actionName: 'E-Mail erneut senden', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Action, + condition: 'pupil_verified_events > 0', + conditionDataAggregations: { pupil_verified_events: { metric: 'pupil_onboarding_verified', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_appointment_booked'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 2, + stepName: 'Kennenlerngespräch buchen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_01', + achievedImage: '', + actionName: 'Termin vereinbaren', + actionRedirectLink: 'https://calendly.com', + actionType: achievement_action_type_enum.Action, + condition: 'pupil_appointment_booked_events > 0', + conditionDataAggregations: { + pupil_appointment_booked_events: { metric: 'pupil_onboarding_appointment_booked', aggregator: 'count' }, + }, + isActive: false, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 3, + stepName: 'Screening absolvieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Screening absolvieren', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Appointment, + condition: 'pupil_screened_events > 0', + conditionDataAggregations: { pupil_screened_events: { metric: 'pupil_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); + + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 4, + stepName: 'Onboarding abgeschlossen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Flugticket', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'pupil_screened_events > 0', + conditionDataAggregations: { pupil_screened_events: { metric: 'pupil_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); + + // STUDENT CONDUCTED MATCH APPOINTMENT + await prisma.achievement_template.create({ + data: { + name: '1. durchgeführter Termin', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_01', + achievedImage: '', + actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', + actionRedirectLink: null, + actionType: achievement_action_type_enum.Action, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 0', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 2, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_02', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 2', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 3, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_03', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 4', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 4, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_04', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 9', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 5, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_05', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 14', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 6, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_06', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 24', + conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + + // PUPIL CONDUCTED MATCH APPOINTMENT + await prisma.achievement_template.create({ + data: { + name: '1. durchgeführter Termin', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_01', + achievedImage: '', + actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', + actionRedirectLink: null, + actionType: achievement_action_type_enum.Action, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 0', + conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 2, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_02', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 2', + conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 3, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_03', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 4', + conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 4, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_04', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 9', + conditionDataAggregations: { student_conducted_match_appointments: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 5, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_05', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 14', + conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 6, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_06', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 24', + conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + isActive: true, + }, + }); + + // PUPIL REGULAR LEARNING + await prisma.achievement_template.create({ + data: { + name: 'Regelmäßiges Lernen', + metrics: ['pupil_match_learned_regular'], + templateFor: achievement_template_for_enum.Match, + group: 'pupil_match_regular_learning', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.STREAK, + subtitle: 'Nachhilfe mit {{matchpartner}}', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Hat_grey', + achievedImage: 'Hat_gold', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Rekord gebrochen.', + condition: 'pupil_match_learning_events > recordValue', + conditionDataAggregations: { + pupil_match_learning_events: { + metric: 'pupil_match_learned_regular', + aggregator: 'lastStreakLength', + createBuckets: 'by_weeks', + bucketAggregator: 'presenceOfEvents', + }, + }, + isActive: true, + }, + }); + + // STUDENT REGULAR LEARNING + await prisma.achievement_template.create({ + data: { + name: 'Regelmäßiges Lernen', + metrics: ['student_match_learned_regular'], + templateFor: achievement_template_for_enum.Match, + group: 'student_match_regular_learning', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.STREAK, + subtitle: 'Nachhilfe mit {{matchpartner}}', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Hat_grey', + achievedImage: 'Hat_gold', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Rekord gebrochen.', + condition: 'student_match_learning_events > recordValue', + conditionDataAggregations: { + student_match_learning_events: { + metric: 'student_match_learned_regular', + aggregator: 'lastStreakLength', + createBuckets: 'by_weeks', + bucketAggregator: 'presenceOfEvents', + }, + }, + isActive: true, + }, + }); + // Add Instructors and Participants after adding Lectures, so that they are also added to the lectures: await addSubcourseInstructor(null, subcourse1, student1); await addSubcourseInstructor(null, subcourse1, student2); diff --git a/utils/helpers.ts b/utils/helpers.ts index d2b63b537..909422226 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -1,8 +1,9 @@ import { compile } from 'handlebars'; import { Context } from '../common/notification/types'; import { getLogger } from '../common/logger/logger'; +import { AchievementContextType } from '../common/achievement/types'; -export const renderTemplate = (template: string, context: Partial, strict = false) => { +export const renderTemplate = (template: string, context: Partial | AchievementContextType, strict = false) => { const log = getLogger('Template Rendering'); if (!template) { log.error('Template string undefined', new Error('Template string undefined')); From 324d20e5fae01e20775626506d0bc4466f096b9e Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:49:06 +0100 Subject: [PATCH 07/58] feat: achievement notification (#924) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map * add first bucket creators * add package swan * add relation resolution * add first draft to evaluate * add function to check for achievements * add achievements by group * add create user achievement * adjusted evaluation * some drafts * adjust create sequential user achievement * add feature flag * add test actions and mutations * add achieved image to db * adjust next step index * fix awarding user * seed db example type swan js parser * feat: evaluation of tiered achievements (#910) feat: filter bucket with achievement creation * feat: evaluation of streaks (#909) * feat: filter bucket with achievement creation * fix: branch update * fix: create time buckets returning buckets * feat: streak evaluation * fix: update streak with correct values * fix: merge clearing * fix inject record value * fix: make it work * fix: work in change requests * fix: safe empty json in db * fix: requested changes * feat: achievement event notification trigger * fix: remove achievement seen * fix: add condition for relation Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: remove test data, change requests * fix: change requests * fix: add name and id to context for actionTakenAt * fix: merge conflicts * fix: add missing actions * fix: spelling mistaek --------- Co-authored-by: LomyW Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> --- common/achievement/index.ts | 24 +++++++++++++++--------- common/notification/actions.ts | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 13a53be89..5ce2a9fc3 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -5,8 +5,9 @@ import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { getTemplatesByAction } from './template'; import { evaluateAchievement } from './evaluate'; -import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementTemplate } from './types'; +import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementContext, UserAchievementTemplate } from './types'; import { createAchievement, getOrCreateUserAchievement } from './create'; +import { actionTakenAt } from '../notification'; const logger = getLogger('Achievement'); @@ -41,7 +42,7 @@ export async function rewardActionTaken(user: User, actionI } } if (achievementToCheck) { - await checkUserAchievement(achievementToCheck as UserAchievementTemplate, context); + await checkUserAchievement(achievementToCheck as UserAchievementTemplate, actionEvent); } } } @@ -72,7 +73,7 @@ async function trackEvent(event: ActionEvent) { return true; } -async function checkUserAchievement(userAchievement: UserAchievementTemplate | undefined, context: SpecificNotificationContext) { +async function checkUserAchievement(userAchievement: UserAchievementTemplate, event: ActionEvent) { const evaluationResult = await isAchievementConditionMet(userAchievement); if (evaluationResult.conditionIsMet) { @@ -80,9 +81,9 @@ async function checkUserAchievement(userAchievement: UserAc const dataAggregationKey = Object.keys(conditionDataAggregations)[0]; const evaluationResultValue = typeof evaluationResult.resultObject[dataAggregationKey] === 'number' ? Number(evaluationResult.resultObject[dataAggregationKey]) : null; - - const awardedAchievement = await awardUser(evaluationResultValue, userAchievement); - await createAchievement(awardedAchievement.template, userAchievement.userId, context); + const awardedAchievement = await rewardUser(evaluationResultValue, userAchievement, event); + const userAchievementContext: UserAchievementContext = {}; + await createAchievement(awardedAchievement.template, userAchievement.userId, userAchievementContext); } else { await prisma.user_achievement.update({ where: { id: userAchievement.id }, @@ -104,14 +105,19 @@ async function isAchievementConditionMet(achievement: UserAchievementTemplate) { return { conditionIsMet, resultObject }; } -async function awardUser(evaluationResult: number, userAchievement: UserAchievementTemplate) { +async function rewardUser(evaluationResult: number, userAchievement: UserAchievementTemplate, event: ActionEvent) { let newRecordValue = null; if (typeof userAchievement.recordValue === 'number' && evaluationResult) { newRecordValue = evaluationResult; } - return await prisma.user_achievement.update({ + const updatedAchievement = await prisma.user_achievement.update({ where: { id: userAchievement.id }, data: { achievedAt: new Date(), recordValue: newRecordValue, isSeen: false }, - select: { id: true, userId: true, achievedAt: true, context: true, template: true }, + select: { id: true, userId: true, achievedAt: true, template: true }, + }); + + await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { + achievement: { name: updatedAchievement.template.name, id: updatedAchievement.id.toString() }, }); + return updatedAchievement; } diff --git a/common/notification/actions.ts b/common/notification/actions.ts index c89370477..70d9d236a 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -634,10 +634,26 @@ const _notificationActions = { description: 'Student / E-Mail verified', sampleContext: {}, }, + + user_achievement_reward_issued: { + description: 'Reward issued', + sampleContext: { + achievement: { + id: '0', + name: 'achievement', + }, + }, + }, student_calendly_appointment_booked: { description: 'Student booked appointment via calendly ', sampleContext: {}, }, + + /* MEETINGS */ + student_joined_meeting: { + description: 'Student joined meeting', + sampleContext: {}, + }, pupil_calendly_appointment_booked: { description: 'Pupil booked appointment via calendly ', sampleContext: {}, From 30137515abc4938ff6280981a85bc32485a22b62 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 13 Dec 2023 15:03:43 +0100 Subject: [PATCH 08/58] chore: todos --- common/achievement/bucket.ts | 1 + common/achievement/evaluate.ts | 2 +- common/achievement/get.ts | 4 ++++ common/achievement/metric.ts | 10 ++++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 93a29d76e..8577d6fb6 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -12,6 +12,7 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, }, by_lecture_start: { + // TODO: do not create a bucket if lecture.declinedBy includes the userId function: async (relation): Promise> => { const [type] = getRelationTypeAndId(relation); diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 9c4ab710f..834c6df1c 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -64,7 +64,7 @@ export async function evaluateAchievement( const value = aggFunction(bucketAggr); resultObject[key] = value; } - + // TODO: return true if the condition is empty (eg. a student finishes a course and automatically receives an achievement) const evaluate = swan.parse(condition); const value: boolean = await evaluate(resultObject); diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 1e22dcec7..e0579d80d 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -6,6 +6,7 @@ import { ConditionDataAggregations } from './types'; import { getAchievementState, getCurrentAchievementTemplateWithContext, transformPrismaJson } from './util'; import { evaluateAchievement } from './evaluate'; +// TODO: getAchievementById -> passed user and achievementId to return a single achievement const getUserAchievements = async (user: User): Promise => { const userAchievements = await prisma.user_achievement.findMany({ where: { userId: user.userID }, @@ -74,6 +75,8 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use maxValue = userAchievements.length - 1; } + // TODO: create a function to get the course image path for an array given templateIds. If the result of this function is undefined, use the template image. + return { id: userAchievements[currentAchievementIndex].id, name: currentAchievementTemplate.name, @@ -97,6 +100,7 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use maxSteps: maxValue, currentStep: currentValue, isNewAchievement: isNewAchievement, + // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template progressDescription: `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, actionName: userAchievements[currentAchievementIndex].template.actionName, actionRedirectLink: userAchievements[currentAchievementIndex].template.actionRedirectLink, diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index 2227b3c60..f646cb5a8 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -26,7 +26,7 @@ const batchOfMetrics = [ createMetric('student_onboarding_verified', ['student_registration_verified_email'], () => { return 1; }), - // TODO - relevant if calendly API is integrated + //! relevant if calendly API is integrated createMetric('student_onboarding_appointment_booked', ['student_calendly_appointment_booked'], () => { return 1; }), @@ -40,7 +40,7 @@ const batchOfMetrics = [ createMetric('pupil_onboarding_verified', ['pupil_registration_verified_email'], () => { return 1; }), - // TODO - relevant if calendly API is integrated + //! relevant if calendly API is integrated createMetric('pupil_onboarding_appointment_booked', ['pupil_calendly_appointment_booked'], () => { return 1; }), @@ -63,6 +63,12 @@ const batchOfMetrics = [ createMetric('student_match_learned_regular', ['student_joined_match_meeting'], () => { return 1; }), + + // TODO: add offer course metric listening to 2 actions - screening_success and course_created + + // TODO: new match metric listening to 2 actions - screening_success and match_requested + + // TODO: attendance and punctuality records only for pupils - actions: pupil_joined_match_meeting, pupil_joined_subcourse_meeting ]; export function registerAchievementMetrics() { From 183fe89db85f89011d2a1e51f43b7a9a469e98ce Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 14 Dec 2023 08:23:17 +0100 Subject: [PATCH 09/58] chore: todos --- common/achievement/get.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index e0579d80d..0d5848b6e 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -7,6 +7,7 @@ import { getAchievementState, getCurrentAchievementTemplateWithContext, transfor import { evaluateAchievement } from './evaluate'; // TODO: getAchievementById -> passed user and achievementId to return a single achievement +// 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 }, From 14ce44a46e50ac0771fdd4f502a5a414fa5a5a56 Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Thu, 14 Dec 2023 09:18:46 +0100 Subject: [PATCH 10/58] feat: invoke achievement action taken (#913) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map * add first bucket creators * add package swan * add relation resolution * add first draft to evaluate * add function to check for achievements * add achievements by group * add create user achievement * feat: backend implementation achievement resolver * adjusted evaluation * some drafts * adjust create sequential user achievement * add feature flag * add test actions and mutations * add achieved image to db * adjust next step index * fix: change requests * fix awarding user * seed db example type swan js parser * feat: evaluation of tiered achievements (#910) feat: filter bucket with achievement creation * feat: evaluation of streaks (#909) * feat: filter bucket with achievement creation * fix: branch update * fix: create time buckets returning buckets * feat: streak evaluation * fix: update streak with correct values * fix: merge clearing * fix: removed unnecessary checks and added comments * fix inject record value * fix: make it work * fix: change index generation for state handling * fix: work in change requests * feat: save and get by relation match and subcourse * add templates to seed db * fix: get next template on index * add metric and actions for student onboarding * trigger onboarding actions * fix: add values to context * fix: safe empty json in db * fix: set right achievement state * - add pupil onboarding, conducted match meetings - student conducted match meetings * fix: create achievement next step index * add metrics and action for pupil onboarding * add achievement mutation resolver * extend meeting action context * add data for regular learned streak * fix: change requests * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: requested changes * fix: update AchievementContext * fix: adjustment of Achievement Context * fix bucket by lecture start * prevent more events for one bucket * fix relation for buckets * fix bucket * get user achievement for create next * fix: introduce helper functions, refactor context * adjust bucketCreator for regular learned to weekly * fix: add condition for relation Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: remove test data, change requests * fix: reset achievedAt when condition not met * fix: type duplication * fix: reflect metadata * fix: reflect metadata * refactor of aggregators * review changes * rename streak aggregator * adjust streak data * sort desc * feat: refactor of aggregators (#926) * refactor of aggregators * review changes * rename streak aggregator * feat: relation context (#911) * feat: save and get by relation match and subcourse * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: update AchievementContext * fix: adjustment of Achievement Context * fix: introduce helper functions, refactor context * fix: type duplication * fix: reflect metadata --------- Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> Co-authored-by: LomyW * feat: achievement is seen (#925) * feat: mutation for seen achievements * fix: change requests * feat: resolver values (#927) * feat: evaluation in resolver * fix: add record value to evaluation * fix: await achievements in resolver * fix: no await to get user achievements Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * fix: merge parent branch * fix: update actions * fix: handle multiple metrics in evaluation value * add valueToAchieve to seed tiered achievements * adjust bucket creator context * fix: requested changes * fix no relation bucket context * fix: update generate achievement data * fix: achievement resolver (#930) * fix: get sequential achievements * fix: find user achievement * fix: preiod length is record value --------- Co-authored-by: Lucas Co-authored-by: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> --- common/achievement/bucket.ts | 34 ++++++++------ common/achievement/create.ts | 18 +++++--- common/achievement/evaluate.ts | 21 ++++++--- common/achievement/get.ts | 78 +++++++++++++++++++++----------- common/achievement/index.ts | 2 +- common/achievement/types.ts | 8 +++- common/achievement/util.ts | 6 +-- common/notification/actions.ts | 22 ++++++--- common/notification/types.ts | 4 +- graphql/achievement/mutations.ts | 4 +- graphql/user/fields.ts | 2 +- seed-db.ts | 48 +++++++++++++++----- 12 files changed, 162 insertions(+), 85 deletions(-) diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 8577d6fb6..f5aebb8c0 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,28 +1,23 @@ import moment from 'moment'; import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket } from './types'; -import { getBucketContext, getRelationTypeAndId } from './util'; type BucketCreatorDefs = Record; // Buckets are needed to pre-sort and aggregate certain events by types / a certain time window (e.g. weekly) etc. export const bucketCreatorDefs: BucketCreatorDefs = { default: { - function: async (): Promise> => { - return await { bucketKind: 'default', buckets: [] }; + function: (): GenericBucketConfig => { + return { bucketKind: 'default', buckets: [] }; }, }, by_lecture_start: { - // TODO: do not create a bucket if lecture.declinedBy includes the userId - function: async (relation): Promise> => { - const [type] = getRelationTypeAndId(relation); + function: (bucketContext): GenericBucketConfig => { + const { context } = bucketContext; - if (!relation) { - return { bucketKind: 'time', buckets: [] }; - } - const context = await getBucketContext(relation); if (!context[context.type].lecture) { return { bucketKind: 'time', buckets: [] }; } + return { bucketKind: 'time', buckets: context[context.type].lecture.map((lecture) => ({ @@ -34,11 +29,20 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, }, by_weeks: { - function: async (_relation, weeks): Promise> => { + function: (context): GenericBucketConfig => { + const { recordValue: weeks } = context; // the buckets are created in a desc order const today = moment(); const buckets: TimeBucket[] = []; + /* + This is to look at the last few weeks before the current event so that we can evaluate whether the streak has been interrupted for the last few weeks or whether we have a new record. + --- + Why do we pass the `recordValue` as weeks / months? + Let's imagine our current record: 6 + We now want to see if this record still exists. We want to know whether the last 7 weeks are correct, because the previous record was 6. + Now it doesn't matter how long the user was inactive or similar. As soon as only one bucket is found among these buckets (7 buckets) that contains nothing, we know that the record has not been surpassed. + */ for (let i = 0; i < weeks + 1; i++) { const weeksBefore = today.clone().subtract(i, 'week'); buckets.push({ @@ -48,14 +52,16 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }); } - return await { + return { bucketKind: 'time', buckets, }; }, }, by_months: { - function: async (_relation, months): Promise> => { + function: (context): GenericBucketConfig => { + const { recordValue: months } = context; + // the buckets are created in a desc order const today = moment(); const buckets: TimeBucket[] = []; @@ -69,7 +75,7 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }); } - return await { + return { bucketKind: 'time', buckets, }; diff --git a/common/achievement/create.ts b/common/achievement/create.ts index b3a4c1b86..52332a1d9 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,11 +1,11 @@ import { Prisma } from '@prisma/client'; -import { Achievement_template, JsonFilter } from '../../graphql/generated'; +import { Achievement_template, JsonFilter, achievement_template_for_enum } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; async function findUserAchievement(templateId: number, userId: string, context: SpecificNotificationContext) { - const keys = Object.keys(context); + const keys = context ? Object.keys(context) : []; const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, @@ -25,20 +25,24 @@ async function findUserAchievement(templateId: number, user } async function getOrCreateUserAchievement(template: Achievement_template, userId: string, context?: SpecificNotificationContext) { - const existingUserAchievement = await findUserAchievement(template.id, userId, context); + 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); if (!existingUserAchievement) { return await createAchievement(template, userId, context); } return existingUserAchievement; } -async function createAchievement(templateToCreate: Achievement_template, userId: string, context: SpecificNotificationContext) { +async function createAchievement(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const keys = Object.keys(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { - group: templateToCreate.group, + group: currentTemplate.group, }, userId, AND: keys.map((key) => { @@ -53,9 +57,9 @@ async function createAchievement(templateToCreate: Achievem orderBy: { template: { groupOrder: 'asc' } }, }); - const nextStepIndex = userAchievementsByGroup.length > 0 ? userAchievementsByGroup.findIndex((e) => e.groupOrder === templateToCreate.groupOrder) + 1 : 0; + const nextStepIndex = userAchievementsByGroup.length > 0 ? userAchievementsByGroup.findIndex((e) => e.groupOrder === currentTemplate.groupOrder) + 1 : 0; - const templatesForGroup = templatesByGroup.get(templateToCreate.group); + const templatesForGroup = templatesByGroup.get(currentTemplate.group); if (templatesForGroup && templatesForGroup.length > nextStepIndex) { const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); return createdUserAchievement; diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 834c6df1c..d1d72f10d 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -1,10 +1,11 @@ import { Achievement_event } from '../../graphql/generated'; -import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; +import { AchievementContextType, BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; import { prisma } from '../prisma'; import { aggregators } from './aggregator'; import swan from '@onlabsorg/swan-js'; import { bucketCreatorDefs } from './bucket'; import { getLogger } from '../logger/logger'; +import { getBucketContext } from './util'; const logger = getLogger('Achievement'); export async function evaluateAchievement( @@ -23,7 +24,7 @@ export async function evaluateAchievement( eventsByMetric[event.metric].push(event); } - const resultObject: Record = {}; + const resultObject: Record = {}; resultObject['recordValue'] = recordValue; for (const key in dataAggregation) { @@ -47,21 +48,27 @@ export async function evaluateAchievement( const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; const bucketAggregatorFunction = aggregators[bucketAggregator].function; - const aggFunction = aggregators[aggregator].function; - if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggFunction) { + const aggregatorFunction = aggregators[aggregator].function; + + if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggregatorFunction) { logger.error( `No bucket creator or aggregator function found for ${bucketCreator}, ${aggregator} or ${bucketAggregator} during the evaluation of achievement` ); return; } - const buckets = await bucketCreatorFunction(relation, recordValue); - const bucketEvents = createBucketEvents(eventsForMetric, buckets); + let bucketContext: AchievementContextType; + if (relation) { + bucketContext = await getBucketContext(relation); + } + const buckets = bucketCreatorFunction({ recordValue: recordValue, context: bucketContext }); + + const bucketEvents = createBucketEvents(eventsForMetric, buckets); const bucketAggr = bucketEvents.map((bucketEvent) => bucketAggregatorFunction(bucketEvent.events.map((event) => event.value))); - const value = aggFunction(bucketAggr); + const value = aggregatorFunction(bucketAggr); resultObject[key] = value; } // TODO: return true if the condition is empty (eg. a student finishes a course and automatically receives an achievement) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 0d5848b6e..f240de367 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -25,20 +25,24 @@ const getUserAchievements = async (user: User): Promise => { }; const generateReorderedAchievementData = async (groups: { [group: string]: User_achievement[] }, user: User): Promise => { - const achievements: Achievement[] = []; - for (const group in groups) { - const sortedGroupAchievements = groups[group].sort((a, b) => a.groupOrder - b.groupOrder); - if (sortedGroupAchievements[0].template.type === achievement_type_enum.TIERED) { - sortedGroupAchievements.forEach(async (groupAchievement, index) => { - const achievement: Achievement = await assembleAchievementData([groupAchievement], user); - achievements.push(achievement); - }); - } else { + const groupKeys = Object.keys(groups); + const achievements = await Promise.all( + groupKeys.map(async (key) => { + const group = groups[key]; + const sortedGroupAchievements = group.sort((a, b) => a.groupOrder - b.groupOrder); + if (sortedGroupAchievements[0].template.type === achievement_type_enum.TIERED) { + return await Promise.all( + sortedGroupAchievements.map(async (groupAchievement) => { + const achievement: Achievement = await assembleAchievementData([groupAchievement], user); + return achievement; + }) + ); + } const groupAchievement: Achievement = await assembleAchievementData(sortedGroupAchievements, user); - achievements.push(groupAchievement); - } - } - return Promise.all(achievements); + return [groupAchievement]; + }) + ); + return Promise.all(achievements.reduce((a, b) => a.concat(b), [])); }; const assembleAchievementData = async (userAchievements: User_achievement[], user: User): Promise => { @@ -48,6 +52,11 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use const achievementContext = await transformPrismaJson(user, userAchievements[currentAchievementIndex].context); const currentAchievementTemplate = getCurrentAchievementTemplateWithContext(userAchievements[currentAchievementIndex], achievementContext); + const achievementTemplates = await prisma.achievement_template.findMany({ + where: { group: currentAchievementTemplate.group }, + orderBy: { groupOrder: 'asc' }, + }); + const state: achievement_state = getAchievementState(userAchievements, currentAchievementIndex); const isNewAchievement = state === achievement_state.COMPLETED && !userAchievements[currentAchievementIndex].isSeen; @@ -59,21 +68,31 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use let maxValue; let currentValue; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { - const dataAggregationKey = Object.keys(currentAchievementTemplate.conditionDataAggregations)[0]; + const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations); const evaluationResult = await evaluateAchievement( condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, currentAchievementTemplate.metrics, userAchievements[currentAchievementIndex].recordValue ); - currentValue = evaluationResult.resultObject[dataAggregationKey]; + currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); maxValue = currentAchievementTemplate.type === achievement_type_enum.STREAK - ? userAchievements[currentAchievementIndex].recordValue - : currentAchievementTemplate.conditionDataAggregations[dataAggregationKey].valueToAchieve || 0; + ? userAchievements[currentAchievementIndex].recordValue > currentValue + ? userAchievements[currentAchievementIndex].recordValue + : currentValue + : dataAggregationKeys + .map((key) => { + return Number(currentAchievementTemplate.conditionDataAggregations[key].valueToAchieve); + }) + .reduce((a, b) => a + b, 0); } else { - currentValue = currentAchievementIndex + 1; - maxValue = userAchievements.length - 1; + const achievementTemplates = await prisma.achievement_template.findMany({ + where: { group: currentAchievementTemplate.group, isActive: true }, + orderBy: { groupOrder: 'asc' }, + }); + currentValue = currentAchievementIndex; + maxValue = achievementTemplates.length - 1; } // TODO: create a function to get the course image path for an array given templateIds. If the result of this function is undefined, use the template image. @@ -89,14 +108,19 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use achievementType: currentAchievementTemplate.type as achievement_type_enum, achievementState: state, steps: currentAchievementTemplate.stepName - ? userAchievements.map((achievement, index) => { - // if a achievementTemplate has a stepName, it means that it must have multiple steps as well as being a sequential achievement - // for every achievement in the sortedGroupAchievements, we create a step object with the stepName (descirption) and isActive property for the achievement step currently active but unachieved - return { - name: achievement.template.stepName, - isActive: index === currentAchievementIndex, - }; - }) + ? achievementTemplates + .map((achievement, index) => { + // if a achievementTemplate has a stepName, it means that it must have multiple steps as well as being a sequential achievement + // for every achievement in the sortedGroupAchievements, we create a step object with the stepName (descirption) and isActive property for the achievement step currently active but unachieved + if (index < achievementTemplates.length - 1 && achievement.isActive) { + return { + name: achievement.stepName, + isActive: index === currentAchievementIndex, + }; + } + return null; + }) + .filter((step) => step) : null, maxSteps: maxValue, currentStep: currentValue, diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 5ce2a9fc3..de0c3bd68 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -65,7 +65,7 @@ async function trackEvent(event: ActionEvent) { value: value, action: event.actionId, userId: event.user.userID, - relation: event.context.relationId ?? '', + relation: event.context.relation ?? '', }, }); } diff --git a/common/achievement/types.ts b/common/achievement/types.ts index e30420e44..dd3678f07 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -37,11 +37,13 @@ export type Bucket = DefaultBucket | TimeBucket; export type BucketEvents = Bucket & { events: Achievement_event[]; }; + export type BucketEventsWithAggr = BucketEvents & { aggregation: number; }; -type BucketFormulaFunction = (relation?: string, numberOfPeriode?: number) => Promise; +type BucketCreatorContext = { recordValue: number; context: AchievementContextType }; +type BucketFormulaFunction = (bucketContext: BucketCreatorContext) => BucketConfig; export type BucketFormula = { function: BucketFormulaFunction; @@ -60,6 +62,8 @@ export type ConditionDataAggregations = { bucketAggregator?: string; // Default: one bucket / event createBuckets?: string; + // For tiered achievements we need the number (max value) that can be achieved (for the resolver) + valueToAchieve?: number; }; }; @@ -95,7 +99,7 @@ export type AchievementToCheck = { export type EvaluationResult = { conditionIsMet: boolean; - resultObject: Record; + resultObject: Record; }; export type RelationTypes = 'match' | 'subcourse'; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 6b2db2219..a5c50425a 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -26,13 +26,11 @@ export function getMetricsByAction(actionId: ID): Metric[] export function getRelationTypeAndId(relation: string): [type: RelationTypes, id: number] { const validRelationTypes = ['match', 'subcourse']; - const [relationType, relationId] = relation.split('/'); + const [relationType, id] = relation.split('/'); if (!validRelationTypes.includes(relationType)) { throw Error('No valid relation found in relation: ' + relationType); } - - const parsedRelationId = parseInt(relationId, 10); - return [relationType as RelationTypes, parsedRelationId]; + return [relationType as RelationTypes, Number(id)]; } export async function getBucketContext(relation: string): Promise { diff --git a/common/notification/actions.ts b/common/notification/actions.ts index 70d9d236a..e3dd39983 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -649,22 +649,32 @@ const _notificationActions = { sampleContext: {}, }, - /* MEETINGS */ - student_joined_meeting: { - description: 'Student joined meeting', - sampleContext: {}, - }, pupil_calendly_appointment_booked: { description: 'Pupil booked appointment via calendly ', 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: { matchId: '1', pupil: { firstname: 'Pupil' }, // = matchpartner - relationId: 'match/1', + relation: 'match/1', }, }, student_joined_subcourse_meeting: { diff --git a/common/notification/types.ts b/common/notification/types.ts index 81425f765..7abacd15b 100644 --- a/common/notification/types.ts +++ b/common/notification/types.ts @@ -34,8 +34,8 @@ export interface NotificationContextExtensions { campaign?: string; // For Campaigns, support sending custom Mailjet Notifications: overrideMailjetTemplateID?: string; - // TODO: For achievements - relationId?: string; + // For Achievements, the match or subcourse is needed as a relation to allocate events to a specific user achievement + relation?: string; } export interface NotificationContext extends NotificationContextExtensions { diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts index 140398b23..63c7411e1 100644 --- a/graphql/achievement/mutations.ts +++ b/graphql/achievement/mutations.ts @@ -19,12 +19,12 @@ export class MutateAchievementResolver { await Notification.actionTaken(user, 'student_joined_match_meeting', { matchId: matchId.toString(), pupil: match.pupil, - relationId: `match/${matchId}`, + relation: `match/${matchId}`, }); } else if (user.pupilId) { await Notification.actionTaken(user, 'pupil_joined_match_meeting', { matchId: matchId.toString(), - relationId: `match/${matchId}`, + relation: `match/${matchId}`, }); } diff --git a/graphql/user/fields.ts b/graphql/user/fields.ts index f12cd5226..856a776a5 100644 --- a/graphql/user/fields.ts +++ b/graphql/user/fields.ts @@ -212,7 +212,7 @@ export class UserFieldsResolver { @FieldResolver((returns) => [Achievement]) @Authorized(Role.ADMIN, Role.OWNER) - achievements(@Ctx() context: GraphQLContext): Promise { + achievements(@Ctx() context: GraphQLContext): Promise { return getUserAchievements(context.user); } diff --git a/seed-db.ts b/seed-db.ts index cda55114e..7f7721586 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -957,7 +957,9 @@ void (async function setupDevDB() { actionType: achievement_action_type_enum.Action, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 0', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + }, isActive: true, }, }); @@ -979,7 +981,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 2', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + }, isActive: true, }, }); @@ -1001,7 +1005,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 4', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + }, isActive: true, }, }); @@ -1023,7 +1029,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 9', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + }, isActive: true, }, }); @@ -1045,7 +1053,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 14', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + }, isActive: true, }, }); @@ -1067,7 +1077,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 24', - conditionDataAggregations: { student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + }, isActive: true, }, }); @@ -1091,7 +1103,9 @@ void (async function setupDevDB() { actionType: achievement_action_type_enum.Action, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 0', - conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + }, isActive: true, }, }); @@ -1113,7 +1127,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 2', - conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + }, isActive: true, }, }); @@ -1135,7 +1151,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 4', - conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + }, isActive: true, }, }); @@ -1157,7 +1175,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 9', - conditionDataAggregations: { student_conducted_match_appointments: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + student_conducted_match_appointments: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + }, isActive: true, }, }); @@ -1179,7 +1199,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 14', - conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + }, isActive: true, }, }); @@ -1201,7 +1223,9 @@ void (async function setupDevDB() { actionType: null, achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 24', - conditionDataAggregations: { pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count' } }, + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + }, isActive: true, }, }); From 6bfd232ff93d197bb3156a7fe0eec6289562f718 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:48:16 +0100 Subject: [PATCH 11/58] feature: add tracing to achievement system (#935) --- common/achievement/create.ts | 6 ++++-- common/achievement/evaluate.ts | 6 +++++- common/achievement/index.ts | 39 +++++++++++++++++++++------------- common/notification/index.ts | 20 ++++++++++------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 52332a1d9..4234e79a3 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,8 +1,9 @@ import { Prisma } from '@prisma/client'; -import { Achievement_template, JsonFilter, achievement_template_for_enum } from '../../graphql/generated'; +import { Achievement_template, achievement_template_for_enum } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; +import tracer from '../logger/tracing'; async function findUserAchievement(templateId: number, userId: string, context: SpecificNotificationContext) { const keys = context ? Object.keys(context) : []; @@ -36,7 +37,8 @@ async function getOrCreateUserAchievement(template: Achieve return existingUserAchievement; } -async function createAchievement(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext) { +const createAchievement = tracer.wrap('achievement.createAchievement', _createAchievement); +async function _createAchievement(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const keys = Object.keys(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index d1d72f10d..4f9466f25 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -6,9 +6,13 @@ import swan from '@onlabsorg/swan-js'; import { bucketCreatorDefs } from './bucket'; import { getLogger } from '../logger/logger'; import { getBucketContext } from './util'; +import tracer from '../logger/tracing'; + const logger = getLogger('Achievement'); -export async function evaluateAchievement( +export const evaluateAchievement = tracer.wrap('achievement.evaluateAchievement', _evaluateAchievement); + +async function _evaluateAchievement( condition: string, dataAggregation: ConditionDataAggregations, metrics: string[], diff --git a/common/achievement/index.ts b/common/achievement/index.ts index de0c3bd68..a908e1292 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -8,14 +8,16 @@ import { evaluateAchievement } from './evaluate'; import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementContext, UserAchievementTemplate } from './types'; import { createAchievement, getOrCreateUserAchievement } from './create'; import { actionTakenAt } from '../notification'; +import tracer from '../logger/tracing'; const logger = getLogger('Achievement'); -export async function rewardActionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { +export const rewardActionTaken = tracer.wrap('achievement.rewardActionTaken', _rewardActionTaken); +async function _rewardActionTaken(user: User, actionId: ID, context: SpecificNotificationContext) { if (!isGamificationFeatureActive()) { return; } - const templatesForAction = await getTemplatesByAction(actionId); + const templatesForAction = await tracer.trace('achievement.getTemplatesByAction', () => getTemplatesByAction(actionId)); if (templatesForAction.length === 0) { logger.debug(`No achievement found for action '${actionId}'`); @@ -30,20 +32,27 @@ export async function rewardActionTaken(user: User, actionI user: user, context, }; - await trackEvent(actionEvent); - - for (const [, group] of templatesByGroups) { - let achievementToCheck: AchievementToCheck; - for (const template of group) { - const userAchievement = await getOrCreateUserAchievement(template, user.userID, context); - if (userAchievement.achievedAt === null || userAchievement.recordValue) { - achievementToCheck = userAchievement; - break; + await tracer.trace('achievement.trackEvent', () => trackEvent(actionEvent)); + + 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; + } } - } - if (achievementToCheck) { - await 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)); + } + }); } } diff --git a/common/notification/index.ts b/common/notification/index.ts index 285e16585..305d22949 100644 --- a/common/notification/index.ts +++ b/common/notification/index.ts @@ -14,7 +14,7 @@ import { Channels } from '../../graphql/types/preferences'; import { ALL_PREFERENCES } from './defaultPreferences'; import assert from 'assert'; import { Prisma } from '@prisma/client'; -import { addTagsToActiveSpan } from '../logger/tracing'; +import tracer, { addTagsToActiveSpan } from '../logger/tracing'; import * as Achievement from '../../common/achievement'; const logger = getLogger('Notification'); @@ -397,14 +397,17 @@ export async function actionTaken( attachments?: AttachmentGroup, noDuplicates = false ) { - if (!user.active) { - logger.debug(`No action '${actionId}' taken for User(${user.userID}) as the account is deactivated`); - return; - } + return await tracer.trace('notification.actionTaken', async (span) => { + span.setTag('actionId', actionId); - await Achievement.rewardActionTaken(user, actionId, notificationContext); + if (!user.active) { + logger.debug(`No action '${actionId}' taken for User(${user.userID}) as the account is deactivated`); + return; + } - return await actionTakenAt(new Date(), user, actionId, notificationContext, false, noDuplicates, attachments); + await Achievement.rewardActionTaken(user, actionId, notificationContext); + return await actionTakenAt(new Date(), user, actionId, notificationContext, false, noDuplicates, attachments); + }); } /* actionTakenAt is the mighty variant of actionTaken: @@ -443,7 +446,8 @@ If 'noDuplicates' is set, a Notification will be ignored if a Notification alrea Otherwise a Notification will just be sent multiple times. */ -export async function actionTakenAt( +export const actionTakenAt = tracer.wrap('notification.actionTakenAt', _actionTakenAt); +export async function _actionTakenAt( at: Date, user: User, actionId: ID, From 1d384a36796052ae3fae9fe0d447911e0dcf1660 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 19 Dec 2023 13:23:22 +0100 Subject: [PATCH 12/58] 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'); +} From 108ea6fa0fd1982c246ee61e75520ab25f1c773d Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Wed, 20 Dec 2023 08:44:55 +0100 Subject: [PATCH 13/58] fix: global achievements (#931) * add first draft of tables * first minor changes * add draft: aggregator, formula, bucket, metric * remove isAchieved * adjusted schema for achievements * add actionTaken() * comment: example import * add function to track events * add relation adjust formula return value * specific context * add migration * add metric and generic formula context * check for achievement action * track context * fix integration * adjust prop for bucket formula * add cached achievement template * register metrics * remove comment * add map for metric by name and by action * adjust for metrics map * add first bucket creators * add package swan * add relation resolution * add first draft to evaluate * add function to check for achievements * add achievements by group * add create user achievement * feat: backend implementation achievement resolver * adjusted evaluation * some drafts * adjust create sequential user achievement * add feature flag * add test actions and mutations * add achieved image to db * adjust next step index * fix: change requests * fix awarding user * seed db example type swan js parser * feat: evaluation of tiered achievements (#910) feat: filter bucket with achievement creation * feat: evaluation of streaks (#909) * feat: filter bucket with achievement creation * fix: branch update * fix: create time buckets returning buckets * feat: streak evaluation * fix: update streak with correct values * fix: merge clearing * fix: removed unnecessary checks and added comments * fix inject record value * fix: make it work * fix: change index generation for state handling * fix: work in change requests * feat: save and get by relation match and subcourse * add templates to seed db * fix: get next template on index * add metric and actions for student onboarding * trigger onboarding actions * fix: add values to context * fix: safe empty json in db * fix: set right achievement state * - add pupil onboarding, conducted match meetings - student conducted match meetings * fix: create achievement next step index * add metrics and action for pupil onboarding * add achievement mutation resolver * extend meeting action context * add data for regular learned streak * fix: change requests * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: requested changes * fix: update AchievementContext * fix: adjustment of Achievement Context * fix bucket by lecture start * prevent more events for one bucket * fix relation for buckets * fix bucket * get user achievement for create next * fix: introduce helper functions, refactor context * adjust bucketCreator for regular learned to weekly * fix: add condition for relation Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> * fix: remove test data, change requests * fix: reset achievedAt when condition not met * fix: type duplication * fix: reflect metadata * fix: reflect metadata * refactor of aggregators * review changes * rename streak aggregator * adjust streak data * sort desc * feat: refactor of aggregators (#926) * refactor of aggregators * review changes * rename streak aggregator * feat: relation context (#911) * feat: save and get by relation match and subcourse * fix: remove empty context * fix: added sampleContext and adjustet mutations * fix: update AchievementContext * fix: adjustment of Achievement Context * fix: introduce helper functions, refactor context * fix: type duplication * fix: reflect metadata --------- Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> Co-authored-by: LomyW * feat: achievement is seen (#925) * feat: mutation for seen achievements * fix: change requests * feat: resolver values (#927) * feat: evaluation in resolver * fix: add record value to evaluation * fix: await achievements in resolver * fix: no await to get user achievements Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * fix: merge parent branch * fix: update actions * fix: handle multiple metrics in evaluation value * add valueToAchieve to seed tiered achievements * adjust bucket creator context * fix: requested changes * fix no relation bucket context * add comments * fix: handle event relations in buckets * fix: adjust evaluateAchievement invocations * fix: update renaming of relationId to relation * fix: merge parent branch * fix: update generate achievement data * fix: achievement resolver (#930) * fix: get sequential achievements * fix: find user achievement * fix: in group order on achievement creation * fix: new relation types for achievement context * fix: recordValue as periodLength * feat: add comments for new features * feat: get inactive achievements * fix: only count lectures that were not declined * fix: current achievement for seqences * fix: change requests * fix: remove context type itteration from buckets * try to unify matches with global_matches (#934) * fix: templates by metrics * Feat: new resolvers (#933) * feat: by id and next step resolvers * fix: achievement field * fix: singular return on getAchievement Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * fix: relation in resolver * fix: pass relation to evaluation --------- Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * fix: change requests * fix: remove unnecessary unused code --------- Co-authored-by: LomyW Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> --- common/achievement/bucket.ts | 68 ++++++++++++++----------- common/achievement/create.ts | 5 +- common/achievement/evaluate.ts | 29 +++++++---- common/achievement/get.ts | 92 ++++++++++++++++++++++++++++++---- common/achievement/index.ts | 8 +-- common/achievement/template.ts | 3 +- common/achievement/types.ts | 26 ++++++---- common/achievement/util.ts | 69 +++++++++++++++++++------ graphql/authorizations.ts | 39 +++++++------- graphql/user/fields.ts | 25 +++++++-- 10 files changed, 261 insertions(+), 103 deletions(-) diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index f5aebb8c0..274267f04 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,8 +1,22 @@ import moment from 'moment'; -import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket } from './types'; +import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket, ContextMatch, ContextSubcourse } from './types'; type BucketCreatorDefs = Record; +function createLectureBuckets(data: T): TimeBucket[] | null { + if (!data.lecture || data.lecture.length === 0) { + return null; + } + // const relation = context.type === ('match' || 'subcourse') ? `${context.type}/${match['id']}` : null; + const buckets: TimeBucket[] = data.lecture.map((lecture) => ({ + kind: 'time', + relation: data.relation, + startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), + endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), + })); + return buckets; +} + // Buckets are needed to pre-sort and aggregate certain events by types / a certain time window (e.g. weekly) etc. export const bucketCreatorDefs: BucketCreatorDefs = { default: { @@ -13,27 +27,22 @@ export const bucketCreatorDefs: BucketCreatorDefs = { by_lecture_start: { function: (bucketContext): GenericBucketConfig => { const { context } = bucketContext; - - if (!context[context.type].lecture) { - return { bucketKind: 'time', buckets: [] }; - } - - return { - bucketKind: 'time', - buckets: context[context.type].lecture.map((lecture) => ({ - kind: 'time', - startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), - endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), - })), - }; + // the context.type is a discriminator to define what relationType is used for the bucket (match, subcourse, global_match, global_subcourse) + // using the context key context[context.type] is equivalent for using a variable key like context.match etc..., meaining that this forEach is iterating over an array of matches/subcourses + const matchBuckets = context.match.map(createLectureBuckets).reduce((acc, val) => acc.concat(val), []); + const subcourseBuckets = context.subcourse.map(createLectureBuckets).reduce((acc, val) => acc.concat(val), []); + return { bucketKind: 'time', buckets: [...matchBuckets, ...subcourseBuckets] }; }, }, by_weeks: { - function: (context): GenericBucketConfig => { - const { recordValue: weeks } = context; + function: (bucketContext): GenericBucketConfig => { + const { recordValue: weeks } = bucketContext; // the buckets are created in a desc order const today = moment(); - const buckets: TimeBucket[] = []; + const timeBucket: GenericBucketConfig = { + bucketKind: 'time', + buckets: [], + }; /* This is to look at the last few weeks before the current event so that we can evaluate whether the streak has been interrupted for the last few weeks or whether we have a new record. @@ -45,40 +54,39 @@ export const bucketCreatorDefs: BucketCreatorDefs = { */ for (let i = 0; i < weeks + 1; i++) { const weeksBefore = today.clone().subtract(i, 'week'); - buckets.push({ + timeBucket.buckets.push({ kind: 'time', + relation: null, startTime: weeksBefore.startOf('week').toDate(), endTime: weeksBefore.endOf('week').toDate(), }); } - return { - bucketKind: 'time', - buckets, - }; + return timeBucket; }, }, by_months: { - function: (context): GenericBucketConfig => { - const { recordValue: months } = context; + function: (bucketContext): GenericBucketConfig => { + const { recordValue: months } = bucketContext; // the buckets are created in a desc order const today = moment(); - const buckets: TimeBucket[] = []; + const timeBucket: GenericBucketConfig = { + bucketKind: 'time', + buckets: [], + }; for (let i = 0; i < months + 1; i++) { const monthsBefore = today.clone().subtract(i, 'month'); - buckets.push({ + timeBucket.buckets.push({ kind: 'time', + relation: null, startTime: monthsBefore.startOf('month').toDate(), endTime: monthsBefore.endOf('month').toDate(), }); } - return { - bucketKind: 'time', - buckets, - }; + return timeBucket; }, }, }; diff --git a/common/achievement/create.ts b/common/achievement/create.ts index ac2cf8325..5fed0964b 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -49,6 +49,8 @@ async function getOrCreateUserAchievement( const createAchievement = tracer.wrap('achievement.createAchievement', _createAchievement); async function _createAchievement(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); + const keys = Object.keys(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { @@ -68,9 +70,8 @@ async function _createAchievement(currentTemplate: Achievem orderBy: { template: { groupOrder: 'asc' } }, }); - const nextStepIndex = userAchievementsByGroup.length > 0 ? userAchievementsByGroup.findIndex((e) => e.groupOrder === currentTemplate.groupOrder) + 1 : 0; + const nextStepIndex = userAchievementsByGroup.length > 0 ? templatesForGroup.findIndex((e) => e.groupOrder === currentTemplate.groupOrder) + 1 : 0; - const templatesForGroup = templatesByGroup.get(currentTemplate.group); if (templatesForGroup && templatesForGroup.length > nextStepIndex) { const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); return createdUserAchievement; diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index df70a5e32..70ffc8963 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -1,5 +1,5 @@ import { Achievement_event } from '../../graphql/generated'; -import { AchievementContextType, BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; +import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult, GenericBucketConfig, TimeBucket } from './types'; import { prisma } from '../prisma'; import { aggregators } from './aggregator'; import swan from '@onlabsorg/swan-js'; @@ -17,9 +17,18 @@ async function _evaluateAchievement( condition: string, dataAggregation: ConditionDataAggregations, metrics: string[], - recordValue: number + recordValue: number, + relation?: string | null ): Promise { - const achievementEvents = await prisma.achievement_event.findMany({ where: { userId, metric: { in: metrics } }, orderBy: { createdAt: 'desc' } }); + // filter: wenn wir eine richtige relation haben -> filtern nach relation + const achievementEvents = await prisma.achievement_event.findMany({ + where: { + userId, + metric: { in: metrics }, + AND: relation ? { relation: { equals: relation } } : {}, + }, + orderBy: { createdAt: 'desc' }, + }); const eventsByMetric: Record = {}; for (const event of achievementEvents) { @@ -46,7 +55,6 @@ async function _evaluateAchievement( const eventsForMetric = eventsByMetric[metricName]; // 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; const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; const bucketAggregatorFunction = aggregators[bucketAggregator].function; @@ -60,12 +68,8 @@ async function _evaluateAchievement( return; } - let bucketContext: AchievementContextType; - if (relation) { - bucketContext = await getBucketContext(relation); - } - - const buckets = bucketCreatorFunction({ recordValue: recordValue, context: bucketContext }); + const bucketContext = await getBucketContext(userId, relation); + const buckets = bucketCreatorFunction({ recordValue, context: bucketContext }); const bucketEvents = createBucketEvents(eventsForMetric, buckets); const bucketAggr = bucketEvents.map((bucketEvent) => bucketAggregatorFunction(bucketEvent.events.map((event) => event.value))); @@ -105,7 +109,10 @@ const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConf const { buckets } = bucketConfig; const bucketsWithEvents: BucketEvents[] = buckets.map((bucket) => { // values will be sorted in a desc order - const filteredEvents = events.filter((event) => event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime); + let filteredEvents = events.filter((event) => event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime); + if (bucket.relation) { + filteredEvents = filteredEvents.filter((event) => event.relation === bucket.relation); + } return { kind: bucket.kind, diff --git a/common/achievement/get.ts b/common/achievement/get.ts index d09334998..f4ab55d1c 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -6,8 +6,80 @@ import { ConditionDataAggregations } from './types'; import { getAchievementState, getCurrentAchievementTemplateWithContext, transformPrismaJson } from './util'; import { evaluateAchievement } from './evaluate'; -// TODO: getAchievementById -> passed user and achievementId to return a single achievement -// TODO: resolver for nextSteps -> get active sequential achievements and important information +const getAchievementById = async (user: User, achievementId: number): Promise => { + const userAchievement = await prisma.user_achievement.findUnique({ + where: { id: achievementId }, + include: { template: true }, + }); + const achievement = await assembleAchievementData([userAchievement], user); + return achievement; +}; + +// Next step achievements are sequential achievements that are currently active and not yet completed. They get displayed in the next step card section. +const getNextStepAchievements = async (user: User): Promise => { + const userAchievements = await prisma.user_achievement.findMany({ + where: { userId: user.userID, isSeen: false, template: { type: achievement_type_enum.SEQUENTIAL } }, + include: { template: true }, + }); + const userAchievementGroups: { [group: string]: User_achievement[] } = {}; + userAchievements.forEach((ua) => { + if (!userAchievementGroups[ua.template.group]) { + userAchievementGroups[ua.template.group] = []; + } + userAchievementGroups[ua.template.group].push(ua); + }); + const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); + return achievements; +}; + +// Inactive achievements are acheievements that are not yet existing but could be achieved in the future. +// They are created for every template in a Tiered achievements group that is not yet used as a achievement for a specific user. +const getFurtherAchievements = async (user: User): Promise => { + const userAchievements = await prisma.user_achievement.findMany({ + where: { userId: user.userID, template: { isActive: true } }, + include: { template: true }, + }); + + const groups = Array.from(new Set(userAchievements.map((ua) => ua.template.group))); + const templates = Array.from(new Set(userAchievements.map((ua) => ua.templateId))); + const tieredTemplates = await prisma.achievement_template.findMany({ + where: { + isActive: true, + group: { in: groups }, + type: achievement_type_enum.TIERED, + NOT: { id: { in: templates } }, + }, + }); + + const tieredAchievements = tieredTemplates.map((template) => { + const dataAggregationKeys = Object.keys(template.conditionDataAggregations); + const maxValue = dataAggregationKeys + .map((key) => { + return Number(template.conditionDataAggregations[key].valueToAchieve); + }) + .reduce((a, b) => a + b, 0); + return { + id: template.id, + name: template.name, + subtitle: template.subtitle, + description: template.description, + image: template.image, + alternativeText: 'alternativeText', + actionType: template.actionType as achievement_action_type_enum, + achievementType: template.type as achievement_type_enum, + achievementState: achievement_state.INACTIVE, + steps: null, + maxSteps: maxValue, + currentStep: 0, + isNewAchievement: null, + progressDescription: `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, + actionName: template.actionName, + actionRedirectLink: template.actionRedirectLink, + }; + }); + return tieredAchievements; +}; +// User achievements are already started by the user and are either active or completed. const getUserAchievements = async (user: User): Promise => { const userAchievements = await prisma.user_achievement.findMany({ where: { userId: user.userID, AND: { template: { isActive: true } } }, @@ -49,7 +121,7 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use let currentAchievementIndex = userAchievements.findIndex((ua) => !ua.achievedAt); currentAchievementIndex = currentAchievementIndex >= 0 ? currentAchievementIndex : userAchievements.length - 1; - const achievementContext = await transformPrismaJson(user, userAchievements[currentAchievementIndex].context); + const achievementContext = transformPrismaJson(user, userAchievements[currentAchievementIndex].context); const currentAchievementTemplate = getCurrentAchievementTemplateWithContext(userAchievements[currentAchievementIndex], achievementContext); const achievementTemplates = await prisma.achievement_template.findMany({ @@ -69,12 +141,14 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use let currentValue; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations); + const relation = userAchievements[currentAchievementIndex].context['relation'] || null; const evaluationResult = await evaluateAchievement( user.userID, condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, currentAchievementTemplate.metrics, - userAchievements[currentAchievementIndex].recordValue + userAchievements[currentAchievementIndex].recordValue, + relation ); currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); maxValue = @@ -111,12 +185,12 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use steps: currentAchievementTemplate.stepName ? achievementTemplates .map((achievement, index) => { - // if a achievementTemplate has a stepName, it means that it must have multiple steps as well as being a sequential achievement + // if a achievementTemplate has a stepName, it means that it must have multiple steps resulting in it having a sequence of achievements / templates // for every achievement in the sortedGroupAchievements, we create a step object with the stepName (descirption) and isActive property for the achievement step currently active but unachieved if (index < achievementTemplates.length - 1 && achievement.isActive) { return { name: achievement.stepName, - isActive: index === currentAchievementIndex, + isActive: index === currentAchievementIndex + 1, }; } return null; @@ -128,9 +202,9 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use isNewAchievement: isNewAchievement, // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template progressDescription: `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, - actionName: userAchievements[currentAchievementIndex].template.actionName, - actionRedirectLink: userAchievements[currentAchievementIndex].template.actionRedirectLink, + actionName: currentAchievementTemplate.actionName, + actionRedirectLink: currentAchievementTemplate.actionRedirectLink, }; }; -export { getUserAchievements }; +export { getUserAchievements, getFurtherAchievements, getNextStepAchievements, getAchievementById }; diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 3e90a8fde..c1d8893e2 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -100,7 +100,7 @@ async function trackEvent(event: ActionEvent) { } async function checkUserAchievement(userAchievement: UserAchievementTemplate, event: ActionEvent) { - const evaluationResult = await isAchievementConditionMet(userAchievement); + const evaluationResult = await isAchievementConditionMet(userAchievement, event); if (evaluationResult.conditionIsMet) { const conditionDataAggregations = userAchievement?.template.conditionDataAggregations as ConditionDataAggregations; @@ -117,10 +117,11 @@ async function checkUserAchievement(userAchievement: UserAc } } -async function isAchievementConditionMet(achievement: UserAchievementTemplate) { +async function isAchievementConditionMet(achievement: UserAchievementTemplate, event: ActionEvent) { const { userId, recordValue, + context, template: { condition, conditionDataAggregations, metrics }, } = achievement; if (!condition) { @@ -132,7 +133,8 @@ async function isAchievementConditionMet(achievement: UserAchievementTemplate) { condition, conditionDataAggregations as ConditionDataAggregations, metrics, - recordValue + recordValue, + event.context.relation ); return { conditionIsMet, resultObject }; } diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 603a7d545..110a933ff 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -47,7 +47,8 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise `${m.metricName}, `)}`); return []; } diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 391f4d89c..4b4c47537 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -27,6 +27,7 @@ export type DefaultBucket = { // Bucket containing events from a specific time frame export type TimeBucket = { kind: 'time'; + relation: string; startTime: Date; endTime: Date; }; @@ -41,6 +42,7 @@ export type BucketEventsWithAggr = BucketEvents & { aggregation: number; }; +// The recordValue is used as a reference for the time bucket creator on how many buckets to create. if the recordValue is 5, then 6 buckets will be created to check the last 6 weeks / monthes type BucketCreatorContext = { recordValue: number; context: AchievementContextType }; type BucketFormulaFunction = (bucketContext: BucketCreatorContext) => BucketConfig; @@ -95,19 +97,23 @@ export type EvaluationResult = { resultObject: Record; }; -export type RelationTypes = 'match' | 'subcourse'; +// match and subcourse are relation types to point to a specific match or subcourse, whereas global_match and global_subcourse are used to point to all matches/subcourses of a user +export type RelationTypes = 'match' | 'subcourse' | 'global_match' | 'global_subcourse'; // match_all, subcourse_all, all type ContextLecture = Pick; +export type ContextMatch = { + id: number; + relation: string | null; // will be null if searching for all matches + lecture: ContextLecture[]; +}; +export type ContextSubcourse = { + id: number; + relation: string | null; // will be null if searching for all subcourses + lecture: ContextLecture[]; +}; export type AchievementContextType = { - type: RelationTypes; user?: User; - match?: { - id: number; - lecture: ContextLecture[]; - }; - subcourse?: { - id: number; - lecture: ContextLecture[]; - }; + match: ContextMatch[]; + subcourse: ContextSubcourse[]; }; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 05689d63b..bc533076f 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -4,31 +4,69 @@ import { AchievementContextType, RelationTypes } from './types'; import { prisma } from '../prisma'; import { Prisma } from '@prisma/client'; import { achievement_state } from '../../graphql/types/achievement'; -import { User } from '../user'; +import { User, getUserTypeAndIdForUserId } from '../user'; import { Achievement_template, User_achievement } from '../../graphql/generated'; import { renderTemplate } from '../../utils/helpers'; -export function getRelationTypeAndId(relation: string): [type: RelationTypes, id: number] { - const validRelationTypes = ['match', 'subcourse']; +function getRelationTypeAndId(relation: string): [type: RelationTypes, id: string] { + const validRelationTypes = ['match', 'subcourse', 'global_match', 'global_subcourse']; const [relationType, id] = relation.split('/'); if (!validRelationTypes.includes(relationType)) { throw Error('No valid relation found in relation: ' + relationType); } - return [relationType as RelationTypes, Number(id)]; + return [relationType as RelationTypes, id]; } -export async function getBucketContext(relation: string): Promise { - const [type, id] = getRelationTypeAndId(relation); +// TODO: fix naming +export async function getBucketContext(myUserID: string, relation?: string): Promise { + const [userType, userId] = getUserTypeAndIdForUserId(myUserID); + + const whereClause = { [`${userType}Id`]: userId }; + + let relationType = null; + if (relation) { + const [relationTypeTmp, relationId] = getRelationTypeAndId(relation); + relationType = relationTypeTmp; + + if (relationId) { + whereClause['id'] = Number(relationId); + } + } + + let matches = []; + if (!relationType || relationType === 'match') { + matches = await prisma.match.findMany({ + where: whereClause, + select: { + id: true, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, + }, + }); + } + + let subcourses = []; + if (!relationType || relationType === 'subcourse') { + subcourses = await prisma.subcourse.findMany({ + where: whereClause, + select: { + id: true, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, + }, + }); + } + + // for global relations we get all matches/subcourses of a user by his own id, whereas for specific relations we get the match/subcourse by its relationId const achievementContext: AchievementContextType = { - type: type, - match: - type === 'match' - ? await prisma.match.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } }) - : null, - subcourse: - type === 'subcourse' - ? await prisma.subcourse.findFirst({ where: { id }, select: { id: true, lecture: { select: { start: true, duration: true } } } }) - : null, + match: matches.map((match) => ({ + id: match.id, + relation: relationType ? `${relationType}/${match.id}` : null, + lecture: match.lecture, + })), + subcourse: subcourses.map((subcourse) => ({ + id: subcourse.id, + relation: relationType ? `${relationType}/${subcourse.id}` : null, + lecture: subcourse.lecture, + })), }; return achievementContext; } @@ -38,7 +76,6 @@ export function transformPrismaJson(user: User, json: Prisma.JsonValue): Achieve return null; } const transformedJson: AchievementContextType = { - type: json['match'] ? 'match' : 'subcourse', user: user, match: json['match'] ? json['match'] : undefined, subcourse: json['subcourse'] ? json['subcourse'] : undefined, diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts index 28986f455..c8f9abd45 100644 --- a/graphql/authorizations.ts +++ b/graphql/authorizations.ts @@ -25,6 +25,7 @@ import { getPupil } from './util'; import { Role } from '../common/user/roles'; import { isDev, isTest } from '../common/util/environment'; import { isAppointmentParticipant } from '../common/appointment/participants'; +import { subcourse } from '@prisma/client'; /* -------------------------- AUTHORIZATION FRAMEWORK ------------------------------------------------------- */ @@ -590,24 +591,26 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = { }), }, Lecture: { - fields: withPublicFields( - { - course_attendance_log: nobody, - subcourseId: nobody, - subcourse: nobody, - student: nobody, - instructorId: nobody, - _count: nobody, - match: adminOrOwner, - matchId: participantOrOwnerOrAdmin, - participantIds: adminOrOwner, - organizerIds: adminOrOwner, - declinedBy: participantOrOwnerOrAdmin, - zoomMeetingId: participantOrOwnerOrAdmin, - zoomMeetingReport: adminOrOwner, - override_meeting_link: participantOrOwnerOrAdmin, - } - ), + // TODO: This is a workaround for presentation purposes + fields: withPublicFields< + Lecture, + 'id' | 'start' | 'duration' | 'createdAt' | 'updatedAt' | 'title' | 'description' | 'appointmentType' | 'isCanceled' | 'matchId' | 'subcourseId' + >({ + course_attendance_log: nobody, + // subcourseId: nobody, + subcourse: nobody, + student: nobody, + instructorId: nobody, + _count: nobody, + match: adminOrOwner, + // matchId: participantOrOwnerOrAdmin, + participantIds: adminOrOwner, + organizerIds: adminOrOwner, + declinedBy: participantOrOwnerOrAdmin, + zoomMeetingId: participantOrOwnerOrAdmin, + zoomMeetingReport: adminOrOwner, + override_meeting_link: participantOrOwnerOrAdmin, + }), }, Participation_certificate: { fields: { diff --git a/graphql/user/fields.ts b/graphql/user/fields.ts index 3c35c2d18..f8b8b956e 100644 --- a/graphql/user/fields.ts +++ b/graphql/user/fields.ts @@ -28,7 +28,7 @@ import { getMyContacts, UserContactType } from '../../common/chat/contacts'; import { generateMeetingSDKJWT, isZoomFeatureActive } from '../../common/zoom/util'; import { getUserZAK, getZoomUsers } from '../../common/zoom/user'; import { ConcreteNotificationState } from '../../common/notification/types'; -import { getUserAchievements } from '../../common/achievement/get'; +import { getAchievementById, getFurtherAchievements, getNextStepAchievements, getUserAchievements } from '../../common/achievement/get'; import { Achievement } from '../types/achievement'; @ObjectType() @@ -223,10 +223,29 @@ export class UserFieldsResolver { return await getAppointmentsForUser(user, take, skip, cursor, direction); } + @FieldResolver((returns) => Achievement) + @Authorized(Role.ADMIN, Role.OWNER) + async achievement(@Ctx() context: GraphQLContext, @Arg('id') id: number): Promise { + const achievement = await getAchievementById(context.user, id); + return achievement; + } + @FieldResolver((returns) => [Achievement]) + @Authorized(Role.ADMIN, Role.OWNER) + async nextStepAchievements(@Ctx() context: GraphQLContext): Promise { + const achievements = await getNextStepAchievements(context.user); + return achievements; + } + @FieldResolver((returns) => [Achievement]) + @Authorized(Role.ADMIN, Role.OWNER) + async furtherAchievements(@Ctx() context: GraphQLContext): Promise { + const achievements = await getFurtherAchievements(context.user); + return achievements; + } @FieldResolver((returns) => [Achievement]) @Authorized(Role.ADMIN, Role.OWNER) - achievements(@Ctx() context: GraphQLContext): Promise { - return getUserAchievements(context.user); + async achievements(@Ctx() context: GraphQLContext): Promise { + const achievements = await getUserAchievements(context.user); + return achievements; } @FieldResolver((returns) => Boolean) From eeeafc7cdcbd650d3b4501dfe3cd5859143a8111 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 20 Dec 2023 14:56:05 +0100 Subject: [PATCH 14/58] fix: add relation to group keys --- common/achievement/get.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index f4ab55d1c..a6bc9eabf 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -21,12 +21,14 @@ const getNextStepAchievements = async (user: User): Promise => { where: { userId: user.userID, isSeen: false, template: { type: achievement_type_enum.SEQUENTIAL } }, include: { template: true }, }); - const userAchievementGroups: { [group: string]: User_achievement[] } = {}; + const userAchievementGroups: { [groupRelation: string]: User_achievement[] } = {}; userAchievements.forEach((ua) => { - if (!userAchievementGroups[ua.template.group]) { - userAchievementGroups[ua.template.group] = []; + const relation = ua.context['relation'] || null; + const key = relation ? `${ua.template.group}/${relation}` : ua.template.group; + if (!userAchievementGroups[key]) { + userAchievementGroups[key] = []; } - userAchievementGroups[ua.template.group].push(ua); + userAchievementGroups[key].push(ua); }); const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); return achievements; @@ -114,7 +116,7 @@ const generateReorderedAchievementData = async (groups: { [group: string]: User_ return [groupAchievement]; }) ); - return Promise.all(achievements.reduce((a, b) => a.concat(b), [])); + return achievements.flat(); }; const assembleAchievementData = async (userAchievements: User_achievement[], user: User): Promise => { From 75ea75bca5db2adcd919a2c596dab15dd4d969af Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 20 Dec 2023 17:11:21 +0100 Subject: [PATCH 15/58] fix: completed progress description --- common/achievement/get.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index a6bc9eabf..4508ed6ab 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -203,7 +203,9 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use currentStep: currentValue, isNewAchievement: isNewAchievement, // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template - progressDescription: `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, + progressDescription: userAchievements[currentAchievementIndex].achievedAt + ? 'Hurra! alle Schritte wurden abgeschlossen' + : `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, actionName: currentAchievementTemplate.actionName, actionRedirectLink: currentAchievementTemplate.actionRedirectLink, }; From 3c8ba94de6d1d032d0c56ad6932a757a14c1a396 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Mon, 8 Jan 2024 17:25:23 +0100 Subject: [PATCH 16/58] Create evaluate unit tests (#943) * feature: add evaluate achievement unit tests --- common/achievement/aggregator.spec.ts | 113 +++++++ common/achievement/aggregator.ts | 6 +- common/achievement/bucket.spec.ts | 277 ++++++++++++++++++ common/achievement/bucket.ts | 7 +- common/achievement/evaluate.spec.ts | 404 ++++++++++++++++++++++++++ common/achievement/evaluate.ts | 23 +- common/achievement/get.ts | 1 - common/achievement/index.ts | 3 +- common/achievement/types.ts | 2 +- jest/singletons.ts | 20 ++ jobs/index.ts | 2 +- package-lock.json | 23 ++ package.json | 8 +- types/custom.d.ts | 2 +- web/index.ts | 2 +- 15 files changed, 870 insertions(+), 23 deletions(-) create mode 100644 common/achievement/aggregator.spec.ts create mode 100644 common/achievement/bucket.spec.ts create mode 100644 common/achievement/evaluate.spec.ts create mode 100644 jest/singletons.ts diff --git a/common/achievement/aggregator.spec.ts b/common/achievement/aggregator.spec.ts new file mode 100644 index 000000000..5e1680a3a --- /dev/null +++ b/common/achievement/aggregator.spec.ts @@ -0,0 +1,113 @@ +import { aggregators } from './aggregator'; + +describe('test sum aggregator', () => { + const tests: { + name: string; + elements: number[]; + expected: number; + }[] = [ + { + name: 'should sum all elements in array', + elements: [1, 2, 3, 4, 5], + expected: 15, + }, + { + name: 'should return 0 for empty array', + elements: [], + expected: 0, + }, + ]; + + it.each(tests)('$name', ({ elements, expected }) => { + expect(aggregators['sum'].function(elements)).toEqual(expected); + }); +}); + +describe('test count aggregator', () => { + const tests: { + name: string; + elements: number[]; + expected: number; + }[] = [ + { + name: 'should count all elements in array', + elements: [1, 2, 3, 4, 5], + expected: 5, + }, + { + name: 'should return 0 for empty array', + elements: [], + expected: 0, + }, + { + name: 'should skip elements that are zero', + elements: [1, 0, 3, 0, 5], + expected: 3, + }, + ]; + + it.each(tests)('$name', ({ elements, expected }) => { + expect(aggregators['count'].function(elements)).toEqual(expected); + }); +}); + +describe('test presenceOfEvents aggregator', () => { + const tests: { + name: string; + elements: number[]; + expected: number; + }[] = [ + { + name: 'should return 1 if at least one element is present', + elements: [1, 2, 3, 4, 5], + expected: 1, + }, + { + name: 'should return 0 for empty array', + elements: [], + expected: 0, + }, + { + name: 'should return 0 if all elements are zero', + elements: [0, 0, 0, 0, 0], + expected: 0, + }, + ]; + + it.each(tests)('$name', ({ elements, expected }) => { + expect(aggregators['presenceOfEvents'].function(elements)).toEqual(expected); + }); +}); + +describe('test lastStreakLength aggregator', () => { + const tests: { + name: string; + elements: number[]; + expected: number; + }[] = [ + { + name: 'should get a streak of five', + elements: [1, 2, 3, 4, 5], + expected: 5, + }, + { + name: 'should not have a streak if no elements are present', + elements: [], + expected: 0, + }, + { + name: 'should reset streak if zero in chain', + elements: [1, 1, 1, 0, 1], + expected: 3, + }, + { + name: 'should have a streak of 0 if first element is zero', + elements: [0, 1, 1, 1, 1], + expected: 0, + }, + ]; + + it.each(tests)('$name', ({ elements, expected }) => { + expect(aggregators['lastStreakLength'].function(elements)).toEqual(expected); + }); +}); diff --git a/common/achievement/aggregator.ts b/common/achievement/aggregator.ts index 478d546e3..e7ea8e4d8 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -12,13 +12,15 @@ export const aggregators: Aggregator = { }, count: { function: (elements): number => { - return elements.length; + // TODO: evaluate if this assumption is correct + return elements.filter((num) => num != 0).length; }, }, // this aggregator should be used to check if min one event exist in a bucket, i.e. if one event happend in one week / one month presenceOfEvents: { function: (elements): number => { - return elements.length > 0 ? 1 : 0; + // TODO: evaluate if this assumption is correct + return elements.filter((num) => num != 0).length > 0 ? 1 : 0; }, }, lastStreakLength: { diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts new file mode 100644 index 000000000..abc01bea0 --- /dev/null +++ b/common/achievement/bucket.spec.ts @@ -0,0 +1,277 @@ +import moment from 'moment'; +import { bucketCreatorDefs } from './bucket'; +import { BucketCreatorContext, ContextMatch, ContextSubcourse, TimeBucket } from './types'; + +describe('test create buckets by_lecture_start', () => { + moment.updateLocale('de', { week: { dow: 1 } }); + jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); + + const today = moment(); + const yesterday = moment().subtract(1, 'day'); + const lastWeek = moment().subtract(1, 'week'); + const twoWeeksAgo = moment().subtract(2, 'week'); + + const tests: { + name: string; + expectedBuckets: TimeBucket[]; + matches?: ContextMatch[]; + subcourses?: ContextSubcourse[]; + }[] = [ + { + name: 'should create one bucket for a match with a single lecture', + expectedBuckets: [ + { + kind: 'time', + startTime: moment('2023-08-14T21:50:00.000Z').toDate(), + endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + relation: 'match', + }, + ], + matches: [{ id: 1, relation: 'match', lecture: [{ start: today.toDate(), duration: 60 }] }], + }, + { + name: 'should create multiple buckets for a match with multiple lectures', + expectedBuckets: [ + { + kind: 'time', + startTime: moment('2023-08-13T21:50:00.000Z').toDate(), + endTime: moment('2023-08-13T23:10:00.000Z').toDate(), + relation: 'match', + }, + { + kind: 'time', + startTime: moment('2023-08-14T21:50:00.000Z').toDate(), + endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + relation: 'match', + }, + ], + matches: [ + { + id: 1, + relation: 'match', + lecture: [ + { start: yesterday.toDate(), duration: 60 }, + { start: today.toDate(), duration: 60 }, + ], + }, + ], + }, + { + name: 'should create one bucket for a subcourse with a single lecture', + expectedBuckets: [ + { + kind: 'time', + startTime: moment('2023-08-14T21:50:00.000Z').toDate(), + endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + relation: 'subcourse', + }, + ], + matches: [{ id: 1, relation: 'subcourse', lecture: [{ start: today.toDate(), duration: 60 }] }], + }, + { + name: 'should create multiple buckets for a subcourse with multiple lectures, sorted desc', + expectedBuckets: [ + { + kind: 'time', + startTime: moment('2023-08-13T21:50:00.000Z').toDate(), + endTime: moment('2023-08-13T23:10:00.000Z').toDate(), + relation: 'subcourse', + }, + { + kind: 'time', + startTime: moment('2023-08-14T21:50:00.000Z').toDate(), + endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + relation: 'subcourse', + }, + ], + subcourses: [ + { + id: 1, + relation: 'subcourse', + lecture: [ + { start: yesterday.toDate(), duration: 60 }, + { start: today.toDate(), duration: 60 }, + ], + }, + ], + }, + { + name: 'should create multiple buckets for multiple subcourses and matches', + expectedBuckets: [ + { + kind: 'time', + startTime: moment('2023-08-14T21:50:00.000Z').toDate(), + endTime: moment('2023-08-14T22:40:00.000Z').toDate(), + relation: 'match', + }, + { + kind: 'time', + startTime: moment('2023-07-31T21:50:00.000Z').toDate(), + endTime: moment('2023-07-31T22:55:00.000Z').toDate(), + relation: 'match', + }, + { + kind: 'time', + startTime: moment('2023-08-13T21:50:00.000Z').toDate(), + endTime: moment('2023-08-13T23:55:00.000Z').toDate(), + relation: 'subcourse', + }, + { + kind: 'time', + startTime: moment('2023-08-07T21:50:00.000Z').toDate(), + endTime: moment('2023-08-08T03:00:00.000Z').toDate(), + relation: 'subcourse', + }, + ], + subcourses: [ + { + id: 1, + relation: 'subcourse', + lecture: [ + { start: yesterday.toDate(), duration: 105 }, + { start: lastWeek.toDate(), duration: 290 }, + ], + }, + ], + matches: [ + { + id: 1, + relation: 'match', + lecture: [ + { start: today.toDate(), duration: 30 }, + { start: twoWeeksAgo.toDate(), duration: 45 }, + ], + }, + ], + }, + { + name: 'should not create any buckets if there are no lectures', + expectedBuckets: [], + matches: [{ id: 1, relation: 'match', lecture: [] }], + }, + ]; + + it.each(tests)('$name', ({ expectedBuckets, matches, subcourses }) => { + const res = bucketCreatorDefs['by_lecture_start'].function({ + recordValue: 0, + context: { + match: matches ?? [], + subcourse: subcourses ?? [], + }, + }); + + expect(res.buckets).toEqual(expectedBuckets); + }); +}); + +describe('test create buckets by_week', () => { + const today = new Date(2023, 7, 15); + + moment.updateLocale('de', { week: { dow: 1 } }); + jest.useFakeTimers().setSystemTime(today); + + const tests: { + name: string; + recordValue: number; + expectedBuckets: TimeBucket[]; + }[] = [ + { + name: 'should create one bucket if recordValue is 0', + recordValue: 0, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, + ], + }, + { + name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', + recordValue: 1, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-06T22:00:00.000Z').toDate(), endTime: moment('2023-08-13T21:59:59.999Z').toDate(), relation: null }, + ], + }, + { + name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', + recordValue: 2, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-06T22:00:00.000Z').toDate(), endTime: moment('2023-08-13T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-07-30T22:00:00.000Z').toDate(), endTime: moment('2023-08-06T21:59:59.999Z').toDate(), relation: null }, + ], + }, + ]; + + it.each(tests)('$name', ({ recordValue, expectedBuckets }) => { + const bucketContext: BucketCreatorContext = { + recordValue, + context: { + match: [], + subcourse: [], + }, + }; + const bucketConfig = bucketCreatorDefs['by_weeks'].function(bucketContext); + expect(bucketConfig).toBeDefined(); + expect(bucketConfig.buckets).toEqual(expectedBuckets); + + // The current timestamp should always be in the first bucket + const firstBucket = bucketConfig.buckets[0] as TimeBucket; + expect(today.getTime()).toBeGreaterThan(firstBucket.startTime.getTime()); + expect(today.getTime()).toBeLessThan(firstBucket.endTime.getTime()); + }); +}); + +describe('test create buckets by_months', () => { + const today = new Date(2023, 7, 15); + + moment.updateLocale('de', { week: { dow: 1 } }); + jest.useFakeTimers().setSystemTime(today); + + const tests: { + name: string; + recordValue: number; + expectedBuckets: TimeBucket[]; + }[] = [ + { + name: 'should create one bucket if recordValue is 0', + recordValue: 0, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, + ], + }, + { + name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', + recordValue: 1, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-06-30T22:00:00.000Z').toDate(), endTime: moment('2023-07-31T21:59:59.999Z').toDate(), relation: null }, + ], + }, + { + name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', + recordValue: 2, + expectedBuckets: [ + { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-06-30T22:00:00.000Z').toDate(), endTime: moment('2023-07-31T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-05-31T22:00:00.000Z').toDate(), endTime: moment('2023-06-30T21:59:59.999Z').toDate(), relation: null }, + ], + }, + ]; + + it.each(tests)('$name', ({ recordValue, expectedBuckets }) => { + const bucketContext: BucketCreatorContext = { + recordValue, + context: { + match: [], + subcourse: [], + }, + }; + const bucketConfig = bucketCreatorDefs['by_months'].function(bucketContext); + expect(bucketConfig).toBeDefined(); + expect(bucketConfig.buckets).toEqual(expectedBuckets); + + // The current timestamp should always be in the first bucket + const firstBucket = bucketConfig.buckets[0] as TimeBucket; + expect(today.getTime()).toBeGreaterThan(firstBucket.startTime.getTime()); + expect(today.getTime()).toBeLessThan(firstBucket.endTime.getTime()); + }); +}); diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 274267f04..1f74720d5 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -3,14 +3,15 @@ import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket, ContextM type BucketCreatorDefs = Record; -function createLectureBuckets(data: T): TimeBucket[] | null { +function createLectureBuckets(data: T): TimeBucket[] { if (!data.lecture || data.lecture.length === 0) { - return null; + return []; } // const relation = context.type === ('match' || 'subcourse') ? `${context.type}/${match['id']}` : null; const buckets: TimeBucket[] = data.lecture.map((lecture) => ({ kind: 'time', relation: data.relation, + // TODO: maybe it's possible to pass the 10 minutes as a parameter to the bucketCreatorDefs startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), })); @@ -36,6 +37,7 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, by_weeks: { function: (bucketContext): GenericBucketConfig => { + // TODO: what if the recordValue is not a number or negative? const { recordValue: weeks } = bucketContext; // the buckets are created in a desc order const today = moment(); @@ -67,6 +69,7 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, by_months: { function: (bucketContext): GenericBucketConfig => { + // TODO: what if the recordValue is not a number or negative? const { recordValue: months } = bucketContext; // the buckets are created in a desc order diff --git a/common/achievement/evaluate.spec.ts b/common/achievement/evaluate.spec.ts new file mode 100644 index 000000000..c6a5fa68c --- /dev/null +++ b/common/achievement/evaluate.spec.ts @@ -0,0 +1,404 @@ +import moment from 'moment'; +import { achievement_event, lecture, match, subcourse } from '@prisma/client'; +import { prismaMock } from '../../jest/singletons'; +import { evaluateAchievement } from './evaluate'; +import { ConditionDataAggregations } from './types'; +import { Prisma } from '@prisma/client'; + +function createTestEvent({ metric, value, relation, ts }: { metric: string; value: number; relation?: string; ts?: Date }): achievement_event { + const eventTs = ts || new Date(); + return { + id: 1, + action: 'test', + metric: metric, + relation: relation, + value: value, + userId: 'student/1', + createdAt: eventTs, + }; +} + +describe('evaluate should throw errors for misconfiguration', () => { + const tests: { + name: string; + dataAggr: ConditionDataAggregations; + }[] = [ + { name: 'should throw error if invalid aggregator was set', dataAggr: { x: { aggregator: 'invalid', metric: 'testMetric' } } }, + { + name: 'should throw error if invalid createBuckets was set', + dataAggr: { x: { aggregator: 'sum', metric: 'testMetric', createBuckets: 'invalid', bucketAggregator: 'sum' } }, + }, + { + name: 'should throw error if invalid bucketAggregator was set', + dataAggr: { x: { aggregator: 'sum', metric: 'testMetric', createBuckets: 'by_weeks', bucketAggregator: 'invalid' } }, + }, + ]; + + it.each(tests)('$name', async ({ dataAggr }) => { + prismaMock.achievement_event.findMany.mockResolvedValue([]); + prismaMock.match.findMany.mockResolvedValue([]); + prismaMock.subcourse.findMany.mockResolvedValue([]); + + await expect(evaluateAchievement('student/1', 'x > 0', dataAggr, 0, undefined)).resolves.toBeUndefined(); + }); +}); + +describe('evaluate condition without default bucket aggregator', () => { + const tests: { + name: string; + expectedResult: boolean; + condition: string; + userId: string; + dataAggr: ConditionDataAggregations; + + events?: achievement_event[]; + }[] = [ + { + name: 'should evaluate condition to true', + expectedResult: true, + condition: 'x > 0', + userId: 'student/1', + dataAggr: { + x: { + aggregator: 'sum', + metric: 'testMetric', + }, + }, + events: [createTestEvent({ metric: 'testMetric', value: 1 })], + }, + { + name: 'should ignore events that are not relevant', + expectedResult: false, + condition: 'x > 0', + userId: 'student/1', + dataAggr: { + x: { + aggregator: 'sum', + metric: 'testMetric', + }, + }, + events: [createTestEvent({ metric: 'irrelevantMetric', value: 1 })], + }, + ]; + + it.each(tests)('$name', async ({ expectedResult, condition, userId, dataAggr, events }) => { + prismaMock.achievement_event.findMany.mockResolvedValue(events || []); + prismaMock.match.findMany.mockResolvedValue([]); + prismaMock.subcourse.findMany.mockResolvedValue([]); + + const res = await evaluateAchievement(userId, condition, dataAggr, 0, undefined); + + expect(res).toBeDefined(); + expect(res.conditionIsMet).toBe(expectedResult); + }); +}); + +describe('evaluate record value condition with time buckets', () => { + moment.updateLocale('de', { week: { dow: 1 } }); + jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); + + const today = moment(); + const yesterday = moment().subtract(1, 'day'); + const lastWeek = moment().subtract(1, 'week'); + const twoWeeksAgo = moment().subtract(2, 'week'); + + const testUserId = 'student/1'; + const tests: { + name: string; + condition: string; + recordValue: number; + expectNewRecord: boolean; + dataAggr: ConditionDataAggregations; + + events?: achievement_event[]; + matches?: match[]; + subcourses?: subcourse[]; + }[] = [ + { + name: 'should achieve new record', + condition: 'currentStreak > recordValue', + expectNewRecord: true, + recordValue: 1, + dataAggr: { + currentStreak: { + aggregator: 'count', + metric: 'testMetric', + bucketAggregator: 'count', + createBuckets: 'by_weeks', + }, + }, + events: [ + createTestEvent({ metric: 'testMetric', value: 1, ts: today.toDate() }), + createTestEvent({ metric: 'testMetric', value: 1, ts: lastWeek.toDate() }), + ], + }, + { + name: 'should not achieve new record if both event are in the same week', + condition: 'currentStreak > recordValue', + expectNewRecord: false, + recordValue: 1, + dataAggr: { + currentStreak: { + aggregator: 'count', + metric: 'testMetric', + bucketAggregator: 'count', + createBuckets: 'by_weeks', + }, + }, + events: [ + createTestEvent({ metric: 'testMetric', value: 1, ts: today.toDate() }), + createTestEvent({ metric: 'testMetric', value: 1, ts: yesterday.toDate() }), + ], + }, + { + name: 'should not achieve new record if there is a gap in the streak', + condition: 'currentStreak > recordValue', + expectNewRecord: false, + recordValue: 1, + dataAggr: { + currentStreak: { + aggregator: 'count', + metric: 'testMetric', + bucketAggregator: 'count', + createBuckets: 'by_weeks', + }, + }, + events: [ + createTestEvent({ metric: 'testMetric', value: 1, ts: today.toDate() }), + // gap of one week + createTestEvent({ metric: 'testMetric', value: 1, ts: twoWeeksAgo.toDate() }), + ], + }, + { + name: 'should not achieve new record if there is a gap in the streak, even if there is a event in the gap but with wrong metric', + condition: 'currentStreak > recordValue', + expectNewRecord: false, + recordValue: 1, + dataAggr: { + currentStreak: { + aggregator: 'count', + metric: 'testMetric', + bucketAggregator: 'count', + createBuckets: 'by_weeks', + }, + }, + events: [ + createTestEvent({ metric: 'testMetric', value: 1, ts: today.toDate() }), + createTestEvent({ metric: 'invalidMetric', value: 1, ts: today.toDate() }), + createTestEvent({ metric: 'testMetric', value: 1, ts: twoWeeksAgo.toDate() }), + ], + }, + { + name: 'should not not crash if no events were found', + condition: 'currentStreak > recordValue', + expectNewRecord: false, + recordValue: 1, + dataAggr: { + currentStreak: { + aggregator: 'count', + metric: 'testMetric', + bucketAggregator: 'count', + createBuckets: 'by_weeks', + }, + }, + events: [], + }, + ]; + + it.each(tests)('$name', async ({ condition, expectNewRecord, recordValue, dataAggr, events, matches, subcourses }) => { + prismaMock.achievement_event.findMany.mockResolvedValue(events || []); + prismaMock.match.findMany.mockResolvedValue(matches || []); + prismaMock.subcourse.findMany.mockResolvedValue(subcourses || []); + + const res = await evaluateAchievement(testUserId, condition, dataAggr, recordValue, undefined); + + expect(res).toBeDefined(); + expect(res.conditionIsMet).toBe(expectNewRecord); + }); +}); + +type SubcourseWithLectures = Prisma.subcourseGetPayload<{ include: { lecture: true } }>; + +function createSubcourse({ lectures }: { lectures: lecture[] }): SubcourseWithLectures { + return { + lecture: lectures, + + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + minGrade: 1, + maxGrade: 13, + maxParticipants: 10, + joinAfterStart: false, + published: true, + publishedAt: new Date(), + cancelled: false, + alreadyPromoted: false, + conversationId: null, + allowChatContactParticipants: false, + allowChatContactProspects: false, + groupChatType: 'NORMAL', + courseId: 1, + }; +} + +type MatchWithLectures = Prisma.matchGetPayload<{ include: { lecture: true } }>; + +function createTestMatch({ lectures }: { lectures: lecture[] }): MatchWithLectures { + return { + lecture: lectures, + + id: 1, + uuid: 'uuid', + dissolved: false, + dissolvedAt: null, + dissolvedBy: null, + dissolveReason: null, + dissolveReasonEnum: null, + didHaveMeeting: true, + proposedTime: null, + createdAt: new Date(), + updatedAt: new Date(), + feedbackToPupilMail: false, + feedbackToStudentMail: false, + followUpToPupilMail: false, + followUpToStudentMail: false, + source: 'imported', + studentFirstMatchRequest: null, + pupilFirstMatchRequest: null, + matchPool: null, + studentId: null, + pupilId: null, + }; +} + +function createLecture({ start }: { start: Date }): lecture { + return { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + + start: start, + duration: 60, + subcourseId: 1, + matchId: null, + + appointmentType: 'group', + title: null, + description: null, + isCanceled: false, + organizerIds: [], + participantIds: [], + declinedBy: [], + zoomMeetingId: null, + zoomMeetingReport: null, + instructorId: null, + override_meeting_link: null, + }; +} + +describe('evaluate bucket with match / subcourse context', () => { + moment.updateLocale('de', { week: { dow: 1 } }); + jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); + + const today = moment(); + const yesterday = moment().subtract(1, 'day'); + const twoDaysAgo = moment().subtract(2, 'day'); + + const testUserId = 'student/1'; + const tests: { + name: string; + condition: string; + expectNewRecord: boolean; + dataAggr: ConditionDataAggregations; + + events?: achievement_event[]; + matches?: match[]; + subcourses?: SubcourseWithLectures[]; + }[] = [ + { + name: 'should get achievement if participated in all lectures of a subcourse', + condition: 'participatedLectures == 3', + expectNewRecord: true, + dataAggr: { + participatedLectures: { + aggregator: 'count', + metric: 'matchLectureParticipation', + createBuckets: 'by_lecture_start', + bucketAggregator: 'count', + }, + }, + events: [ + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: today.subtract(5, 'minute').toDate() }), + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: twoDaysAgo.add(5, 'minute').toDate() }), + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: yesterday.subtract(3, 'minute').toDate() }), + ], + subcourses: [ + createSubcourse({ + lectures: [ + createLecture({ start: twoDaysAgo.toDate() }), + createLecture({ start: yesterday.toDate() }), + createLecture({ start: today.toDate() }), + ], + }), + ], + }, + { + name: 'should get achievement if participated in at least 3 lectures of a match', + condition: 'participatedLectures >= 3', + expectNewRecord: true, + dataAggr: { + participatedLectures: { + aggregator: 'count', + metric: 'matchLectureParticipation', + createBuckets: 'by_lecture_start', + bucketAggregator: 'count', + }, + }, + events: [ + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: today.subtract(5, 'minute').toDate() }), + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: twoDaysAgo.add(5, 'minute').toDate() }), + createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: yesterday.subtract(3, 'minute').toDate() }), + ], + matches: [ + createTestMatch({ + lectures: [ + createLecture({ start: twoDaysAgo.toDate() }), + createLecture({ start: yesterday.toDate() }), + createLecture({ start: today.toDate() }), + ], + }), + ], + }, + { + name: 'should not count event if too far away from lecture start', + condition: 'participatedLectures > 0', + expectNewRecord: true, + dataAggr: { + participatedLectures: { + aggregator: 'count', + metric: 'matchLectureParticipation', + createBuckets: 'by_lecture_start', + bucketAggregator: 'count', + }, + }, + events: [createTestEvent({ metric: 'matchLectureParticipation', value: 1, ts: today.subtract(1, 'hour').toDate() })], + matches: [ + createTestMatch({ + lectures: [createLecture({ start: today.toDate() })], + }), + ], + }, + ]; + + it.each(tests)('$name', async ({ expectNewRecord, condition, dataAggr, events, matches, subcourses }) => { + prismaMock.achievement_event.findMany.mockResolvedValue(events || []); + prismaMock.match.findMany.mockResolvedValue(matches || []); + prismaMock.subcourse.findMany.mockResolvedValue(subcourses || []); + + const res = await evaluateAchievement(testUserId, condition, dataAggr, 0, undefined); + + expect(res).toBeDefined(); + expect(res.conditionIsMet).toBe(expectNewRecord); + }); +}); diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 70ffc8963..2c42c2977 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -1,5 +1,5 @@ import { Achievement_event } from '../../graphql/generated'; -import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult, GenericBucketConfig, TimeBucket } from './types'; +import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; import { prisma } from '../prisma'; import { aggregators } from './aggregator'; import swan from '@onlabsorg/swan-js'; @@ -16,10 +16,11 @@ async function _evaluateAchievement( userId: string, condition: string, dataAggregation: ConditionDataAggregations, - metrics: string[], recordValue: number, - relation?: string | null -): Promise { + relation?: string +): Promise { + // We only care about metrics that are used for the data aggregation + const metrics = Object.values(dataAggregation).map((entry) => entry.metric); // filter: wenn wir eine richtige relation haben -> filtern nach relation const achievementEvents = await prisma.achievement_event.findMany({ where: { @@ -53,21 +54,21 @@ async function _evaluateAchievement( const aggregator = dataAggregationObject.aggregator; - const eventsForMetric = eventsByMetric[metricName]; + const eventsForMetric = eventsByMetric[metricName] ?? []; // we take the relation from the first event, that posesses one, in order to create buckets from it, if needed - const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; - const bucketAggregatorFunction = aggregators[bucketAggregator].function; - - const aggregatorFunction = aggregators[aggregator].function; - - if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggregatorFunction) { + if (!bucketCreatorDefs[bucketCreator] || !aggregators[bucketAggregator] || !aggregators[aggregator]) { logger.error( `No bucket creator or aggregator function found for ${bucketCreator}, ${aggregator} or ${bucketAggregator} during the evaluation of achievement` ); return; } + const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; + const bucketAggregatorFunction = aggregators[bucketAggregator].function; + + const aggregatorFunction = aggregators[aggregator].function; + const bucketContext = await getBucketContext(userId, relation); const buckets = bucketCreatorFunction({ recordValue, context: bucketContext }); diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 4508ed6ab..72da878d0 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -148,7 +148,6 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use user.userID, condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, - currentAchievementTemplate.metrics, userAchievements[currentAchievementIndex].recordValue, relation ); diff --git a/common/achievement/index.ts b/common/achievement/index.ts index c1d8893e2..9d8a15bd6 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -122,7 +122,7 @@ async function isAchievementConditionMet(achievement: UserA userId, recordValue, context, - template: { condition, conditionDataAggregations, metrics }, + template: { condition, conditionDataAggregations }, } = achievement; if (!condition) { logger.error(`No condition found for achievement ${achievement.template.name}`); @@ -132,7 +132,6 @@ async function isAchievementConditionMet(achievement: UserA userId, condition, conditionDataAggregations as ConditionDataAggregations, - metrics, recordValue, event.context.relation ); diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 4b4c47537..7b6d9e84e 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -43,7 +43,7 @@ export type BucketEventsWithAggr = BucketEvents & { }; // The recordValue is used as a reference for the time bucket creator on how many buckets to create. if the recordValue is 5, then 6 buckets will be created to check the last 6 weeks / monthes -type BucketCreatorContext = { recordValue: number; context: AchievementContextType }; +export type BucketCreatorContext = { recordValue: number; context: AchievementContextType }; type BucketFormulaFunction = (bucketContext: BucketCreatorContext) => BucketConfig; export type BucketFormula = { diff --git a/jest/singletons.ts b/jest/singletons.ts new file mode 100644 index 000000000..19bae36a1 --- /dev/null +++ b/jest/singletons.ts @@ -0,0 +1,20 @@ +import { PrismaClient } from '@prisma/client'; +import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'; + +jest.mock('../common/prisma', () => ({ + __esModule: true, + + get prisma() { + return prismaMock; + }, +})); + +beforeEach(() => { + mockReset(prismaMock); +}); + +export const prismaMock = mockDeep() as unknown as DeepMockProxy<{ + // this is needed to resolve the issue with circular types definition + // https://github.com/prisma/prisma/issues/10203 + [K in keyof PrismaClient]: Omit; +}>; diff --git a/jobs/index.ts b/jobs/index.ts index a2581b7f2..edd787210 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -22,7 +22,7 @@ const log = getLogger(); log.info('Backend started'); //SETUP: moment -moment.locale('de'); //set global moment date format +moment.locale('de', { dow: 1 }); //set global moment date format + ensure that week is starting on monday moment.tz.setDefault('Europe/Berlin'); //set global timezone (which is then used also for cron job scheduling and moment.format calls) //SETUP: Add a graceful shutdown to the scheduler used diff --git a/package-lock.json b/package-lock.json index ec6d0989e..e4bee7fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-lernfair-lint": "./linter/", "jest": "^29.5.0", + "jest-mock-extended": "^3.0.5", "lint-staged": "^14.0.1", "nodemon": "^3.0.1", "pre-commit": "^1.2.2", @@ -10813,6 +10814,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.5.tgz", + "integrity": "sha512-/eHdaNPUAXe7f65gHH5urc8SbRVWjYxBqmCgax2uqOBJy8UUcCBMN1upj1eZ8y/i+IqpyEm4Kq0VKss/GCCTdw==", + "dev": true, + "dependencies": { + "ts-essentials": "^7.0.3" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -14757,6 +14771,15 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=3.7.0" + } + }, "node_modules/ts-jest": { "version": "29.0.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.5.tgz", diff --git a/package.json b/package.json index f845c2860..457c06683 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-lernfair-lint": "./linter/", "jest": "^29.5.0", + "jest-mock-extended": "^3.0.5", "lint-staged": "^14.0.1", "nodemon": "^3.0.1", "pre-commit": "^1.2.2", @@ -164,7 +165,12 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "verbose": true, + "clearMocks": true, + "setupFilesAfterEnv": [ + "/jest/singletons.ts" + ] }, "nodemonConfig": { "verbose": true, diff --git a/types/custom.d.ts b/types/custom.d.ts index cb9b86a48..c15b4e390 100644 --- a/types/custom.d.ts +++ b/types/custom.d.ts @@ -1,4 +1,4 @@ declare module '@onlabsorg/swan-js' { - declare function parse(condition: any): (context: any) => boolean; + declare function parse(condition: any): (context: any) => Promise; export { parse }; } diff --git a/web/index.ts b/web/index.ts index f64501d6e..1c5173eb1 100644 --- a/web/index.ts +++ b/web/index.ts @@ -11,7 +11,7 @@ import { registerAchievementMetrics } from '../common/achievement/metric'; const logger = getLogger('WebServer'); logger.debug('Debug logging enabled'); -moment.locale('de'); //set global moment date format +moment.locale('de', { dow: 1 }); //set global moment date format + ensure that week is starting on monday moment.tz.setDefault('Europe/Berlin'); //set global timezone (which is then used also for cron job scheduling and moment.format calls) registerAchievementMetrics(); From 01d91c2eb6ff865d31f6798331b15f60bb042b74 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Thu, 11 Jan 2024 10:17:32 +0100 Subject: [PATCH 17/58] feature: use remote s3 URL for achievement images (#946) --- common/achievement/get.ts | 5 +++-- common/achievement/util.ts | 12 ++++++++++ seed-db.ts | 46 +++++++++++++++++++------------------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 72da878d0..7377cce16 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -5,6 +5,7 @@ import { User } from '../user'; import { ConditionDataAggregations } from './types'; import { getAchievementState, getCurrentAchievementTemplateWithContext, transformPrismaJson } from './util'; import { evaluateAchievement } from './evaluate'; +import { getAchievementImageURL } from './util'; const getAchievementById = async (user: User, achievementId: number): Promise => { const userAchievement = await prisma.user_achievement.findUnique({ @@ -65,7 +66,7 @@ const getFurtherAchievements = async (user: User): Promise => { name: template.name, subtitle: template.subtitle, description: template.description, - image: template.image, + image: getAchievementImageURL(template.image), alternativeText: 'alternativeText', actionType: template.actionType as achievement_action_type_enum, achievementType: template.type as achievement_type_enum, @@ -178,7 +179,7 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use name: currentAchievementTemplate.name, subtitle: currentAchievementTemplate.subtitle, description: currentAchievementTemplate.description, - image: currentAchievementTemplate.image, + image: getAchievementImageURL(currentAchievementTemplate.image), alternativeText: 'alternativeText', actionType: currentAchievementTemplate.actionType as achievement_action_type_enum, achievementType: currentAchievementTemplate.type as achievement_type_enum, diff --git a/common/achievement/util.ts b/common/achievement/util.ts index bc533076f..e9e3a83e7 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -1,13 +1,25 @@ import 'reflect-metadata'; // ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html import { AchievementContextType, RelationTypes } from './types'; +import { join } from 'path'; import { prisma } from '../prisma'; import { Prisma } from '@prisma/client'; +import { accessURLForKey } from '../file-bucket'; import { achievement_state } from '../../graphql/types/achievement'; import { User, getUserTypeAndIdForUserId } from '../user'; import { Achievement_template, User_achievement } from '../../graphql/generated'; import { renderTemplate } from '../../utils/helpers'; +export const ACHIEVEMENT_IMAGE_DEFAULT_PATH = 'gamification/achievements'; + +export function getAchievementImageKey(imageKey: string) { + return join(ACHIEVEMENT_IMAGE_DEFAULT_PATH, `${imageKey}`); +} + +export function getAchievementImageURL(imageKey: string) { + return accessURLForKey(imageKey); +} + function getRelationTypeAndId(relation: string): [type: RelationTypes, id: string] { const validRelationTypes = ['match', 'subcourse', 'global_match', 'global_subcourse']; const [relationType, id] = relation.split('/'); diff --git a/seed-db.ts b/seed-db.ts index a3939ed4f..3d71a3ef0 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -758,7 +758,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_00', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/empty_state.png', achievedImage: '', actionName: 'E-Mail erneut senden', actionRedirectLink: '', @@ -779,7 +779,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_01', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_1.png', achievedImage: '', actionName: 'Termin vereinbaren', actionRedirectLink: 'https://calendly.com', @@ -802,7 +802,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_02', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_2.png', achievedImage: '', actionName: 'Screening absolvieren', actionRedirectLink: '', @@ -823,7 +823,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_02', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_3.png', achievedImage: '', actionName: 'Zeugnis einreichen', actionRedirectLink: 'mailto:fz@lern-fair.de', @@ -844,7 +844,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Flugticket', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_4.png', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -866,7 +866,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_00', + image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/empty_state.png', achievedImage: '', actionName: 'E-Mail erneut senden', actionRedirectLink: '', @@ -887,7 +887,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_01', + image: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_1.png', achievedImage: '', actionName: 'Termin vereinbaren', actionRedirectLink: 'https://calendly.com', @@ -910,7 +910,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Puzzle_02', + image: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_2.png', achievedImage: '', actionName: 'Screening absolvieren', actionRedirectLink: '', @@ -932,7 +932,7 @@ void (async function setupDevDB() { type: achievement_type_enum.SEQUENTIAL, subtitle: 'Jetzt durchstarten', description: 'Dieser Text muss noch geliefert werden.', - image: 'Flugticket', + image: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_3.png', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -955,7 +955,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_01', + image: 'gamification/achievements/tmp/x_lectures_held/one_lectures_held.jpg', achievedImage: '', actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', actionRedirectLink: null, @@ -979,7 +979,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_02', + image: 'gamification/achievements/tmp/x_lectures_held/three_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1003,7 +1003,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_03', + image: 'gamification/achievements/tmp/x_lectures_held/five_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1027,7 +1027,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_04', + image: 'gamification/achievements/tmp/x_lectures_held/ten_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1051,7 +1051,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_05', + image: 'gamification/achievements/tmp/x_lectures_held/fifteen_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1075,7 +1075,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_06', + image: 'gamification/achievements/tmp/x_lectures_held/twentyfive_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1101,7 +1101,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_01', + image: 'gamification/achievements/tmp/x_lectures_held/one_lectures_held.jpg', achievedImage: '', actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', actionRedirectLink: null, @@ -1125,7 +1125,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_02', + image: 'gamification/achievements/tmp/x_lectures_held/three_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1149,7 +1149,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_03', + image: 'gamification/achievements/tmp/x_lectures_held/five_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1173,7 +1173,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_04', + image: 'gamification/achievements/tmp/x_lectures_held/ten_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1197,7 +1197,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_05', + image: 'gamification/achievements/tmp/x_lectures_held/fifteen_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1221,7 +1221,7 @@ void (async function setupDevDB() { type: achievement_type_enum.TIERED, subtitle: '1:1 Lernunterstützungen', description: 'Dieser Text muss noch geliefert werden.', - image: 'Polaroid_06', + image: 'gamification/achievements/tmp/x_lectures_held/twentyfive_lectures_held.jpg', achievedImage: '', actionName: null, actionRedirectLink: null, @@ -1247,7 +1247,7 @@ void (async function setupDevDB() { type: achievement_type_enum.STREAK, subtitle: 'Nachhilfe mit {{matchpartner}}', description: 'Dieser Text muss noch geliefert werden.', - image: 'Hat_grey', + image: 'gamification/achievements/tmp/finished_course_sucessfully/finished_course_sucessfully.jpg', achievedImage: 'Hat_gold', actionName: null, actionRedirectLink: null, @@ -1278,7 +1278,7 @@ void (async function setupDevDB() { type: achievement_type_enum.STREAK, subtitle: 'Nachhilfe mit {{matchpartner}}', description: 'Dieser Text muss noch geliefert werden.', - image: 'Hat_grey', + image: 'gamification/achievements/tmp/finished_course_sucessfully/finished_course_sucessfully.jpg', achievedImage: 'Hat_gold', actionName: null, actionRedirectLink: null, From 7e79a50985c6ddad0387247532ba21d2ffc3c141 Mon Sep 17 00:00:00 2001 From: LomyW <115979086+LomyW@users.noreply.github.com> Date: Thu, 11 Jan 2024 12:14:06 +0100 Subject: [PATCH 18/58] feat: achievement notifications (#945) * add achievement notification type * add migration * disable achievement notification via email --- common/notification/defaultPreferences.ts | 2 ++ common/notification/types.ts | 1 + .../migration.sql | 2 ++ prisma/schema.prisma | 1 + 4 files changed, 6 insertions(+) create mode 100644 prisma/migrations/20240111080348_add_achievement_notification_type/migration.sql diff --git a/common/notification/defaultPreferences.ts b/common/notification/defaultPreferences.ts index 5cd0e94ce..2621d7bc4 100644 --- a/common/notification/defaultPreferences.ts +++ b/common/notification/defaultPreferences.ts @@ -10,6 +10,7 @@ export const ENABLED_NOTIFICATIONS: Preferences = { course: { email: true }, certificate: { email: true }, legacy: { email: true }, + achievement: { email: false }, }; export const ENABLED_NEWSLETTER: Preferences = { @@ -72,6 +73,7 @@ const categories = [ 'event', 'request', 'alternative', + 'achievement', ]; export const ALL_PREFERENCES: Preferences = Object.assign(ENABLED_NOTIFICATIONS, DEFAULT_PREFERENCES); diff --git a/common/notification/types.ts b/common/notification/types.ts index 3f8b95c50..12b539d36 100644 --- a/common/notification/types.ts +++ b/common/notification/types.ts @@ -131,4 +131,5 @@ export enum NotificationType { COURSE = 'course', CERTIFICATE = 'certificate', LEGACY = 'legacy', + ACHIEVEMENT = 'achievement', } diff --git a/prisma/migrations/20240111080348_add_achievement_notification_type/migration.sql b/prisma/migrations/20240111080348_add_achievement_notification_type/migration.sql new file mode 100644 index 000000000..ad1ee75e7 --- /dev/null +++ b/prisma/migrations/20240111080348_add_achievement_notification_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "notification_type_enum" ADD VALUE 'achievement'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70292bd91..6595dad12 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1330,6 +1330,7 @@ enum notification_type_enum { course certificate legacy + achievement } enum important_information_language_enum { From af827c4c0c6a2f6e1592434100e3884a0aa542db Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Thu, 11 Jan 2024 17:59:43 +0100 Subject: [PATCH 19/58] fix: use proper subcourse relation --- common/achievement/util.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/common/achievement/util.ts b/common/achievement/util.ts index e9e3a83e7..230c234c4 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -33,7 +33,7 @@ function getRelationTypeAndId(relation: string): [type: RelationTypes, id: strin export async function getBucketContext(myUserID: string, relation?: string): Promise { const [userType, userId] = getUserTypeAndIdForUserId(myUserID); - const whereClause = { [`${userType}Id`]: userId }; + const whereClause = {}; let relationType = null; if (relation) { @@ -48,7 +48,7 @@ export async function getBucketContext(myUserID: string, relation?: string): Pro let matches = []; if (!relationType || relationType === 'match') { matches = await prisma.match.findMany({ - where: whereClause, + where: { ...whereClause, [`${userType}Id`]: userId }, select: { id: true, lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, @@ -58,8 +58,14 @@ export async function getBucketContext(myUserID: string, relation?: string): Pro let subcourses = []; if (!relationType || relationType === 'subcourse') { + let subcourseWhere = whereClause; + if (userType === 'student') { + subcourseWhere = { ...subcourseWhere, subcourse_instructors_student: { some: { studentId: userId } } }; + } else { + subcourseWhere = { ...subcourseWhere, subcourse_participants_pupil: { some: { pupilId: userId } } }; + } subcourses = await prisma.subcourse.findMany({ - where: whereClause, + where: subcourseWhere, select: { id: true, lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, From a0cae611f6b3963cd9958d1897b626d48aa89596 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Thu, 11 Jan 2024 18:46:21 +0100 Subject: [PATCH 20/58] feature: add _userActionTaken admin mutation to test gamification --- graphql/admin.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/graphql/admin.ts b/graphql/admin.ts index cd13a1f20..d0fa4a06a 100644 --- a/graphql/admin.ts +++ b/graphql/admin.ts @@ -6,6 +6,8 @@ import { jobExists } from '../jobs/list'; import { UserInputError } from 'apollo-server-express'; import { runJob } from '../jobs/execute'; import { Doc } from './util'; +import { getUser } from '../common/user'; +import { actionTaken } from '../common/notification'; // Mutations for managing the backend, should usually only be used for testing purposes @@ -46,4 +48,13 @@ export class AdminMutationsResolver { resetRateLimits(); return true; } + + @Mutation((returns) => Boolean) + @Authorized(Role.ADMIN) + @Doc('Triggers actionTaken for the given user; Will be used for gamification testing.') + async _userActionTaken(@Arg('action') action: string, @Arg('userID') userID: string) { + const user = await getUser(userID); + await actionTaken(user, action as any, {}); + return true; + } } From e23fc60a2b258a122991156961ef9716f7dfd3b7 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 13 Jan 2024 20:20:55 +0100 Subject: [PATCH 21/58] feature: add more logs to achievement system --- common/achievement/evaluate.ts | 11 ++++++++--- common/achievement/index.ts | 34 ++++++++++++++++++++++++++++------ common/achievement/util.ts | 20 ++++++++++++-------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index 2c42c2977..f7f45c3bd 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -58,11 +58,16 @@ async function _evaluateAchievement( // we take the relation from the first event, that posesses one, in order to create buckets from it, if needed if (!bucketCreatorDefs[bucketCreator] || !aggregators[bucketAggregator] || !aggregators[aggregator]) { - logger.error( - `No bucket creator or aggregator function found for ${bucketCreator}, ${aggregator} or ${bucketAggregator} during the evaluation of achievement` - ); + logger.error(`No bucket creator or aggregator function found during the evaluation of achievement`, null, { + bucketCreator, + aggregator, + bucketAggregator, + dataKey: key, + metric: metricName, + }); return; } + logger.info('Using aggregator functions', { bucketCreator, aggregator, bucketAggregator, dataKey: key, metric: metricName }); const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function; const bucketAggregatorFunction = aggregators[bucketAggregator].function; diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 9d8a15bd6..14420a0a3 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -26,9 +26,14 @@ async function _rewardActionTaken(user: User, actionId: ID, const templatesForAction = await tracer.trace('achievement.getTemplatesByMetrics', () => getTemplatesByMetrics(metricsForAction)); if (templatesForAction.length === 0) { - logger.debug(`No achievement found for action '${actionId}'`); + logger.debug(`No achievement found for action`, { actionId }); return; } + logger.info('found achievement templates for action', { + actionId, + metrics: metricsForAction.map((metric) => metric.metricName), + templates: templatesForAction.map((temp) => temp.name), + }); const templatesByGroups = sortActionTemplatesToGroups(templatesForAction); @@ -40,7 +45,7 @@ async function _rewardActionTaken(user: User, actionId: ID, }; const isEventTracked = await tracer.trace('achievement.trackEvent', () => trackEvent(actionEvent)); if (!isEventTracked) { - logger.warn(`Can't track event for action '${actionId}' for user '${user.userID}'`); + logger.warn(`Can't track action for user`, { actionId, userId: user.userID }); return; } @@ -48,12 +53,19 @@ async function _rewardActionTaken(user: User, actionId: ID, try { await tracer.trace('achievement.evaluateAchievementGroups', async (span) => { span.setTag('achievement.group', groupName); + logger.info('evaluate 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) { + logger.info('found achievement to check', { + achievementId: userAchievement.id, + achievementName: userAchievement.template.name, + type: userAchievement.template.type, + }); achievementToCheck = userAchievement; break; } @@ -65,9 +77,10 @@ async function _rewardActionTaken(user: User, actionId: ID, checkUserAchievement(achievementToCheck as UserAchievementTemplate, actionEvent) ); } + logger.info('group evaluation done', { groupName }); }); } catch (e) { - logger.error(`Error occurred while checking achievement for user '${user.userID}'`, e); + logger.error(`Error occurred while checking achievement for user`, e, { userId: user.userID }); } } } @@ -76,7 +89,7 @@ async function trackEvent(event: ActionEvent) { const metricsForEvent = getMetricsByAction(event.actionId); if (metricsForEvent.length === 0) { - logger.warn(`Can't track event, because no metrics found for action '${event.actionId}'`); + logger.warn(`Can't track event, because no metrics found for action`, { actionId: event.actionId }); return false; } @@ -84,6 +97,7 @@ async function trackEvent(event: ActionEvent) { const formula = metric.formula; const value = formula(event.context); + logger.info('track event', { metric: metric.metricName, action: event.actionId, value, relation: event.context.relation ?? '', createdAt: event.at }); await prisma.achievement_event.create({ data: { metric: metric.metricName, @@ -101,6 +115,13 @@ async function trackEvent(event: ActionEvent) { async function checkUserAchievement(userAchievement: UserAchievementTemplate, event: ActionEvent) { const evaluationResult = await isAchievementConditionMet(userAchievement, event); + logger.info('sucessfully evaluated achievement condition', { + actionId: event.actionId, + achievementId: userAchievement.id, + condition: userAchievement.template.condition, + conditionIsMet: evaluationResult.conditionIsMet, + resultObject: JSON.stringify(evaluationResult.resultObject, null, 4), + }); if (evaluationResult.conditionIsMet) { const conditionDataAggregations = userAchievement?.template.conditionDataAggregations as ConditionDataAggregations; @@ -121,11 +142,11 @@ async function isAchievementConditionMet(achievement: UserA const { userId, recordValue, - context, template: { condition, conditionDataAggregations }, } = achievement; if (!condition) { - logger.error(`No condition found for achievement ${achievement.template.name}`); + logger.error(`No condition found for achievement`, null, { template: achievement.template.name, achievementId: achievement.id }); + return { conditionIsMet: false, resultObject: null }; } const { conditionIsMet, resultObject } = await evaluateAchievement( @@ -143,6 +164,7 @@ async function rewardUser(evaluationResult: number, userAch if (typeof userAchievement.recordValue === 'number' && evaluationResult) { newRecordValue = evaluationResult; } + logger.info('reward user', { achievementId: userAchievement.id, userId: userAchievement.userId, recordValue: newRecordValue, achievedAt: new Date() }); const updatedAchievement = await prisma.user_achievement.update({ where: { id: userAchievement.id }, data: { achievedAt: new Date(), recordValue: newRecordValue, isSeen: false }, diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 230c234c4..54678fc8c 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -9,6 +9,9 @@ import { achievement_state } from '../../graphql/types/achievement'; import { User, getUserTypeAndIdForUserId } from '../user'; import { Achievement_template, User_achievement } from '../../graphql/generated'; import { renderTemplate } from '../../utils/helpers'; +import { getLogger } from '../logger/logger'; + +const logger = getLogger('Achievement'); export const ACHIEVEMENT_IMAGE_DEFAULT_PATH = 'gamification/achievements'; @@ -29,9 +32,8 @@ function getRelationTypeAndId(relation: string): [type: RelationTypes, id: strin return [relationType as RelationTypes, id]; } -// TODO: fix naming -export async function getBucketContext(myUserID: string, relation?: string): Promise { - const [userType, userId] = getUserTypeAndIdForUserId(myUserID); +export async function getBucketContext(userID: string, relation?: string): Promise { + const [userType, id] = getUserTypeAndIdForUserId(userID); const whereClause = {}; @@ -45,13 +47,15 @@ export async function getBucketContext(myUserID: string, relation?: string): Pro } } + logger.info('evaluate bucket configuration', { userType, relation, relationType, whereClause }); + let matches = []; if (!relationType || relationType === 'match') { matches = await prisma.match.findMany({ - where: { ...whereClause, [`${userType}Id`]: userId }, + where: { ...whereClause, [`${userType}Id`]: id }, select: { id: true, - lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${id}`] } } }, select: { start: true, duration: true } }, }, }); } @@ -60,15 +64,15 @@ export async function getBucketContext(myUserID: string, relation?: string): Pro if (!relationType || relationType === 'subcourse') { let subcourseWhere = whereClause; if (userType === 'student') { - subcourseWhere = { ...subcourseWhere, subcourse_instructors_student: { some: { studentId: userId } } }; + subcourseWhere = { ...subcourseWhere, subcourse_instructors_student: { some: { studentId: id } } }; } else { - subcourseWhere = { ...subcourseWhere, subcourse_participants_pupil: { some: { pupilId: userId } } }; + subcourseWhere = { ...subcourseWhere, subcourse_participants_pupil: { some: { pupilId: id } } }; } subcourses = await prisma.subcourse.findMany({ where: subcourseWhere, select: { id: true, - lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${userId}`] } } }, select: { start: true, duration: true } }, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${id}`] } } }, select: { start: true, duration: true } }, }, }); } From b7657de5f31ffd7e5d961c189cc199e903dacb8f Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sun, 14 Jan 2024 16:34:51 +0100 Subject: [PATCH 22/58] fix: unit tests should work in ci --- common/achievement/bucket.spec.ts | 5 ++++- common/achievement/evaluate.spec.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts index abc01bea0..e5dcfbfc5 100644 --- a/common/achievement/bucket.spec.ts +++ b/common/achievement/bucket.spec.ts @@ -1,9 +1,10 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; import { bucketCreatorDefs } from './bucket'; import { BucketCreatorContext, ContextMatch, ContextSubcourse, TimeBucket } from './types'; describe('test create buckets by_lecture_start', () => { moment.updateLocale('de', { week: { dow: 1 } }); + moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); @@ -168,6 +169,7 @@ describe('test create buckets by_week', () => { const today = new Date(2023, 7, 15); moment.updateLocale('de', { week: { dow: 1 } }); + moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(today); const tests: { @@ -224,6 +226,7 @@ describe('test create buckets by_months', () => { const today = new Date(2023, 7, 15); moment.updateLocale('de', { week: { dow: 1 } }); + moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(today); const tests: { diff --git a/common/achievement/evaluate.spec.ts b/common/achievement/evaluate.spec.ts index 312fe000d..af18a38e6 100644 --- a/common/achievement/evaluate.spec.ts +++ b/common/achievement/evaluate.spec.ts @@ -1,4 +1,4 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; import { achievement_event, lecture, match, subcourse } from '@prisma/client'; import { prismaMock } from '../../jest/singletons'; import { evaluateAchievement } from './evaluate'; @@ -95,6 +95,7 @@ describe('evaluate condition without default bucket aggregator', () => { describe('evaluate record value condition with time buckets', () => { moment.updateLocale('de', { week: { dow: 1 } }); + moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); @@ -300,6 +301,7 @@ function createLecture({ start }: { start: Date }): lecture { describe('evaluate bucket with match / subcourse context', () => { moment.updateLocale('de', { week: { dow: 1 } }); + moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); From b73b3dcc3d3fbb0d38d675baa92b223b2eb730f9 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 15 Jan 2024 13:01:08 +0100 Subject: [PATCH 23/58] fix: get correct active achievement, fixed image alignment --- common/achievement/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 7377cce16..59bd8aef8 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -192,7 +192,7 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use if (index < achievementTemplates.length - 1 && achievement.isActive) { return { name: achievement.stepName, - isActive: index === currentAchievementIndex + 1, + isActive: index === currentAchievementIndex, }; } return null; From b3cfa90f53677c54b0dd83bbbd50346bf987d658 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 19 Jan 2024 13:23:37 +0100 Subject: [PATCH 24/58] feat: subcourse meeting join --- common/notification/actions.ts | 6 ++++-- graphql/achievement/mutations.ts | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/common/notification/actions.ts b/common/notification/actions.ts index 161b2a2bd..d95b92848 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -670,7 +670,8 @@ const _notificationActions = { student_joined_subcourse_meeting: { description: 'Student joined subcourse meeting', sampleContext: { - subcourseId: '1', + relation: 'subcourse/1', + subcourseLecturesCount: '5', }, }, pupil_joined_match_meeting: { @@ -682,7 +683,8 @@ const _notificationActions = { pupil_joined_subcourse_meeting: { description: 'Pupil joined subcourse meeting', sampleContext: { - subcourseId: '1', + relation: 'subcourse/1', + subcourseLecturesCount: '5', }, }, TEST: { diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts index cd1205521..bb235acc6 100644 --- a/graphql/achievement/mutations.ts +++ b/graphql/achievement/mutations.ts @@ -30,4 +30,28 @@ export class MutateAchievementResolver { return true; } + + @Mutation((returns) => Boolean) + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('matchId') subcourseId: number) { + const { user } = context; + const subcourse = await prisma.subcourse.findUnique({ where: { id: subcourseId }, include: { course: true, lecture: true } }); + await hasAccess(context, 'Subcourse', subcourse); + + if (user.studentId) { + const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.isCanceled ? 0 : 1), 0); + await Notification.actionTaken(user, 'student_joined_subcourse_meeting', { + relation: `subcourse/${subcourseId}`, + subcourseLecturesCount: lecturesCount.toString(), + }); + } else if (user.pupilId) { + const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.declinedBy.includes(user.userID) ? 0 : 1), 0); + await Notification.actionTaken(user, 'pupil_joined_subcourse_meeting', { + relation: `subcourse/${subcourseId}`, + subcourseLecturesCount: lecturesCount.toString(), + }); + } + + return true; + } } From ca1425671cd4ec8de590c3450dccf07095ab9013 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 19 Jan 2024 14:54:37 +0100 Subject: [PATCH 25/58] feat: conduct subcourse appointment metric and mutation adjustments --- common/achievement/metric.ts | 8 ++++++++ common/achievement/template.ts | 5 ++++- graphql/achievement/mutations.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index f646cb5a8..3d17e7de3 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -56,6 +56,14 @@ const batchOfMetrics = [ return 1; }), + /* CONDUCTED SUBCOURSE APPOINTMENT */ + createMetric('student_conducted_subcourse_appointment', ['student_joined_subcourse_meeting'], () => { + return 1; + }), + createMetric('pupil_conducted_subcourse_appointment', ['pupil_joined_subcourse_meeting'], () => { + return 1; + }), + /* REGULAR MATCH LEARNING */ createMetric('pupil_match_learned_regular', ['pupil_joined_match_meeting'], () => { return 1; diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 110a933ff..b284d68c4 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -57,7 +57,10 @@ async function getTemplatesByMetrics(metricsForAction: Metric[]) { return []; } for (const metric of metricsForAction) { - templatesForAction = [...templatesForAction, ...templatesByMetric.get(metric.metricName)]; + const templatesForMetric = templatesByMetric.get(metric.metricName); + if (templatesForMetric) { + templatesForAction = [...templatesForAction, ...templatesForMetric]; + } } return templatesForAction; } diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts index bb235acc6..5c93e39cc 100644 --- a/graphql/achievement/mutations.ts +++ b/graphql/achievement/mutations.ts @@ -33,7 +33,7 @@ export class MutateAchievementResolver { @Mutation((returns) => Boolean) @AuthorizedDeferred(Role.ADMIN, Role.OWNER) - async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('matchId') subcourseId: number) { + async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('subcourseId') subcourseId: number) { const { user } = context; const subcourse = await prisma.subcourse.findUnique({ where: { id: subcourseId }, include: { course: true, lecture: true } }); await hasAccess(context, 'Subcourse', subcourse); From 429bb08bb35986bf5b2dfd68550d92020ffaa49c Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sat, 20 Jan 2024 12:55:39 +0100 Subject: [PATCH 26/58] fix: unit tests should work in ci (#958) --- .github/workflows/node.js.yml | 2 +- common/achievement/bucket.spec.ts | 67 ++++++++++++++--------------- common/achievement/evaluate.spec.ts | 2 - package.json | 2 +- 4 files changed, 34 insertions(+), 39 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 88779bab0..d2a44df9c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -2,7 +2,7 @@ name: Master Barrier on: pull_request: - branches: [master] + types: ['opened', 'edited', 'reopened', 'synchronize'] jobs: integration: diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts index e5dcfbfc5..0964eb238 100644 --- a/common/achievement/bucket.spec.ts +++ b/common/achievement/bucket.spec.ts @@ -4,7 +4,6 @@ import { BucketCreatorContext, ContextMatch, ContextSubcourse, TimeBucket } from describe('test create buckets by_lecture_start', () => { moment.updateLocale('de', { week: { dow: 1 } }); - moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); @@ -23,8 +22,8 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-14T21:50:00.000Z').toDate(), - endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:10:00.000Z').toDate(), relation: 'match', }, ], @@ -35,14 +34,14 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-13T21:50:00.000Z').toDate(), - endTime: moment('2023-08-13T23:10:00.000Z').toDate(), + startTime: moment('2023-08-13T23:50:00.000Z').toDate(), + endTime: moment('2023-08-14T01:10:00.000Z').toDate(), relation: 'match', }, { kind: 'time', - startTime: moment('2023-08-14T21:50:00.000Z').toDate(), - endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:10:00.000Z').toDate(), relation: 'match', }, ], @@ -62,8 +61,8 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-14T21:50:00.000Z').toDate(), - endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:10:00.000Z').toDate(), relation: 'subcourse', }, ], @@ -74,14 +73,14 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-13T21:50:00.000Z').toDate(), - endTime: moment('2023-08-13T23:10:00.000Z').toDate(), + startTime: moment('2023-08-13T23:50:00.000Z').toDate(), + endTime: moment('2023-08-14T01:10:00.000Z').toDate(), relation: 'subcourse', }, { kind: 'time', - startTime: moment('2023-08-14T21:50:00.000Z').toDate(), - endTime: moment('2023-08-14T23:10:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:10:00.000Z').toDate(), relation: 'subcourse', }, ], @@ -101,26 +100,26 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-14T21:50:00.000Z').toDate(), - endTime: moment('2023-08-14T22:40:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T00:40:00.000Z').toDate(), relation: 'match', }, { kind: 'time', - startTime: moment('2023-07-31T21:50:00.000Z').toDate(), - endTime: moment('2023-07-31T22:55:00.000Z').toDate(), + startTime: moment('2023-07-31T23:50:00.000Z').toDate(), + endTime: moment('2023-08-01T00:55:00.000Z').toDate(), relation: 'match', }, { kind: 'time', - startTime: moment('2023-08-13T21:50:00.000Z').toDate(), - endTime: moment('2023-08-13T23:55:00.000Z').toDate(), + startTime: moment('2023-08-13T23:50:00.000Z').toDate(), + endTime: moment('2023-08-14T01:55:00.000Z').toDate(), relation: 'subcourse', }, { kind: 'time', - startTime: moment('2023-08-07T21:50:00.000Z').toDate(), - endTime: moment('2023-08-08T03:00:00.000Z').toDate(), + startTime: moment('2023-08-07T23:50:00.000Z').toDate(), + endTime: moment('2023-08-08T05:00:00.000Z').toDate(), relation: 'subcourse', }, ], @@ -169,7 +168,6 @@ describe('test create buckets by_week', () => { const today = new Date(2023, 7, 15); moment.updateLocale('de', { week: { dow: 1 } }); - moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(today); const tests: { @@ -181,24 +179,24 @@ describe('test create buckets by_week', () => { name: 'should create one bucket if recordValue is 0', recordValue: 0, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999').toDate(), relation: null }, ], }, { name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', recordValue: 1, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-08-06T22:00:00.000Z').toDate(), endTime: moment('2023-08-13T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-07T00:00:00.000Z').toDate(), endTime: moment('2023-08-13T23:59:59.999Z').toDate(), relation: null }, ], }, { name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', recordValue: 2, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-13T22:00:00.000Z').toDate(), endTime: moment('2023-08-20T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-08-06T22:00:00.000Z').toDate(), endTime: moment('2023-08-13T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-07-30T22:00:00.000Z').toDate(), endTime: moment('2023-08-06T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-07T00:00:00.000Z').toDate(), endTime: moment('2023-08-13T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-07-31T00:00:00.000Z').toDate(), endTime: moment('2023-08-06T23:59:59.999Z').toDate(), relation: null }, ], }, ]; @@ -226,7 +224,6 @@ describe('test create buckets by_months', () => { const today = new Date(2023, 7, 15); moment.updateLocale('de', { week: { dow: 1 } }); - moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(today); const tests: { @@ -238,24 +235,24 @@ describe('test create buckets by_months', () => { name: 'should create one bucket if recordValue is 0', recordValue: 0, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, ], }, { name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', recordValue: 1, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-06-30T22:00:00.000Z').toDate(), endTime: moment('2023-07-31T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-07-01T00:00:00.000').toDate(), endTime: moment('2023-07-31T23:59:59.999Z').toDate(), relation: null }, ], }, { name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', recordValue: 2, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-07-31T22:00:00.000Z').toDate(), endTime: moment('2023-08-31T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-06-30T22:00:00.000Z').toDate(), endTime: moment('2023-07-31T21:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-05-31T22:00:00.000Z').toDate(), endTime: moment('2023-06-30T21:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-07-01T00:00:00.000Z').toDate(), endTime: moment('2023-07-31T23:59:59.999Z').toDate(), relation: null }, + { kind: 'time', startTime: moment('2023-06-01T00:00:00.000Z').toDate(), endTime: moment('2023-06-30T23:59:59.999Z').toDate(), relation: null }, ], }, ]; diff --git a/common/achievement/evaluate.spec.ts b/common/achievement/evaluate.spec.ts index af18a38e6..7c6ae6877 100644 --- a/common/achievement/evaluate.spec.ts +++ b/common/achievement/evaluate.spec.ts @@ -95,7 +95,6 @@ describe('evaluate condition without default bucket aggregator', () => { describe('evaluate record value condition with time buckets', () => { moment.updateLocale('de', { week: { dow: 1 } }); - moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); @@ -301,7 +300,6 @@ function createLecture({ start }: { start: Date }): lecture { describe('evaluate bucket with match / subcourse context', () => { moment.updateLocale('de', { week: { dow: 1 } }); - moment.tz.setDefault('Europe/Berlin'); jest.useFakeTimers().setSystemTime(new Date(2023, 7, 15)); const today = moment(); diff --git a/package.json b/package.json index 457c06683..d23da6d22 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "heroku:release": "./release.sh", "integration-tests": "node -r source-map-support/register -r dotenv/config ./built/integration-tests/index.js dotenv_config_path=.env.integration-tests", "integration-tests:debug": "node -r source-map-support/register -r dotenv/config ./built/integration-tests/index.js dotenv_config_path=.env.integration-tests.debug", - "test": "jest", + "test": "TZ=UTC jest", "db:reset": "prisma migrate reset --skip-generate", "db:reset-hard": "prisma migrate reset -f --skip-generate", "db:seed": "node -r dotenv/config ./built/seed-db.js", From 70831c439ec9a5bdb20783ccc252abf3a7f7398c Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Mon, 22 Jan 2024 16:30:46 +0100 Subject: [PATCH 27/58] Update common/achievement/util.ts Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> --- common/achievement/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 54678fc8c..51046e84d 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -105,7 +105,7 @@ export function transformPrismaJson(user: User, json: Prisma.JsonValue): Achieve return transformedJson; } -export function getCurrentAchievementTemplateWithContext(userAchievement: User_achievement, achievementContext: AchievementContextType): Achievement_template { +export function renderAchievementWithContext(userAchievement: User_achievement, achievementContext: AchievementContextType): Achievement_template { const currentAchievementContext = userAchievement.template as Achievement_template; const templateKeys = Object.keys(userAchievement.template); templateKeys.forEach((key) => { From d729ed4579d6b3a2dcc23657d097736600674ac0 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 08:39:03 +0100 Subject: [PATCH 28/58] fix: change requests --- common/achievement/create.ts | 2 +- common/achievement/get.ts | 55 +++++++++++++++++++++----------- common/achievement/metrics.ts | 2 +- common/achievement/template.ts | 2 +- common/achievement/util.ts | 21 +++++++++--- common/notification/actions.ts | 1 - graphql/achievement/mutations.ts | 23 ------------- graphql/match/mutations.ts | 23 +++++++++++++ graphql/types/achievement.ts | 12 +++++-- seed-db.ts | 2 +- 10 files changed, 89 insertions(+), 54 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 5fed0964b..dc960a01c 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -33,7 +33,7 @@ async function findUserAchievement( async function getOrCreateUserAchievement( template: Achievement_template, userId: string, - context?: SpecificNotificationContext + context: SpecificNotificationContext ): Promise { const isGlobal = template.templateFor === achievement_template_for_enum.Global || diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 59bd8aef8..e819b99cb 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -1,12 +1,22 @@ import { prisma } from '../prisma'; -import { User_achievement, achievement_action_type_enum, achievement_type_enum } from '../../graphql/generated'; +import { achievement_type_enum, Prisma } from '@prisma/client'; import { Achievement, achievement_state } from '../../graphql/types/achievement'; import { User } from '../user'; import { ConditionDataAggregations } from './types'; -import { getAchievementState, getCurrentAchievementTemplateWithContext, transformPrismaJson } from './util'; +import { getAchievementState, renderAchievementWithContext, transformPrismaJson } from './util'; import { evaluateAchievement } from './evaluate'; import { getAchievementImageURL } from './util'; +export async function getUserAchievementsWithTemplates(user: User) { + const userAchievementsWithTemplates = await prisma.user_achievement.findMany({ + where: { userId: user.userID, AND: { template: { isActive: true } } }, + include: { template: true }, + }); + return userAchievementsWithTemplates; +} +type ThenArg = T extends PromiseLike ? U : T; +export type achievements_with_template = ThenArg>; + const getAchievementById = async (user: User, achievementId: number): Promise => { const userAchievement = await prisma.user_achievement.findUnique({ where: { id: achievementId }, @@ -22,7 +32,7 @@ const getNextStepAchievements = async (user: User): Promise => { where: { userId: user.userID, isSeen: false, template: { type: achievement_type_enum.SEQUENTIAL } }, include: { template: true }, }); - const userAchievementGroups: { [groupRelation: string]: User_achievement[] } = {}; + const userAchievementGroups: { [groupRelation: string]: achievements_with_template } = {}; userAchievements.forEach((ua) => { const relation = ua.context['relation'] || null; const key = relation ? `${ua.template.group}/${relation}` : ua.template.group; @@ -61,15 +71,15 @@ const getFurtherAchievements = async (user: User): Promise => { return Number(template.conditionDataAggregations[key].valueToAchieve); }) .reduce((a, b) => a + b, 0); - return { + const achievement: Achievement = { id: template.id, name: template.name, subtitle: template.subtitle, description: template.description, image: getAchievementImageURL(template.image), alternativeText: 'alternativeText', - actionType: template.actionType as achievement_action_type_enum, - achievementType: template.type as achievement_type_enum, + actionType: template.actionType, + achievementType: template.type, achievementState: achievement_state.INACTIVE, steps: null, maxSteps: maxValue, @@ -79,16 +89,15 @@ const getFurtherAchievements = async (user: User): Promise => { actionName: template.actionName, actionRedirectLink: template.actionRedirectLink, }; + return achievement; }); return tieredAchievements; }; + // User achievements are already started by the user and are either active or completed. const getUserAchievements = async (user: User): Promise => { - const userAchievements = await prisma.user_achievement.findMany({ - where: { userId: user.userID, AND: { template: { isActive: true } } }, - include: { template: true }, - }); - const userAchievementGroups: { [group: string]: User_achievement[] } = {}; + const userAchievements = await getUserAchievementsWithTemplates(user); + const userAchievementGroups: { [group: string]: achievements_with_template } = {}; userAchievements.forEach((ua) => { if (!userAchievementGroups[ua.template.group]) { userAchievementGroups[ua.template.group] = []; @@ -99,12 +108,20 @@ const getUserAchievements = async (user: User): Promise => { return achievements; }; -const generateReorderedAchievementData = async (groups: { [group: string]: User_achievement[] }, user: User): Promise => { +export const achievement_with_template = Prisma.validator()({ + include: { template: true }, +}); +const generateReorderedAchievementData = async (groups: { [group: string]: achievements_with_template }, user: User): Promise => { const groupKeys = Object.keys(groups); const achievements = await Promise.all( groupKeys.map(async (key) => { const group = groups[key]; const sortedGroupAchievements = group.sort((a, b) => a.groupOrder - b.groupOrder); + /** + * This Assembles individual achievements for tiered milestones. Tiered achievements represent steps on the path to higher scores. + * Unlike sequential achievements, each tier is processed separately and displayed on the frontend as a distinct achievement. + * The code checks if the first achievement in the sorted group is of type 'TIERED' and, if so, asynchronously assembles the data for each groupAchievement individually. + */ if (sortedGroupAchievements[0].template.type === achievement_type_enum.TIERED) { return await Promise.all( sortedGroupAchievements.map(async (groupAchievement) => { @@ -120,15 +137,15 @@ const generateReorderedAchievementData = async (groups: { [group: string]: User_ return achievements.flat(); }; -const assembleAchievementData = async (userAchievements: User_achievement[], user: User): Promise => { +const assembleAchievementData = async (userAchievements: achievements_with_template, user: User): Promise => { let currentAchievementIndex = userAchievements.findIndex((ua) => !ua.achievedAt); currentAchievementIndex = currentAchievementIndex >= 0 ? currentAchievementIndex : userAchievements.length - 1; const achievementContext = transformPrismaJson(user, userAchievements[currentAchievementIndex].context); - const currentAchievementTemplate = getCurrentAchievementTemplateWithContext(userAchievements[currentAchievementIndex], achievementContext); + const currentAchievementTemplate = renderAchievementWithContext(userAchievements[currentAchievementIndex], achievementContext); const achievementTemplates = await prisma.achievement_template.findMany({ - where: { group: currentAchievementTemplate.group }, + where: { group: currentAchievementTemplate.group, isActive: true }, orderBy: { groupOrder: 'asc' }, }); @@ -140,8 +157,8 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use ? currentAchievementTemplate.condition.replace('recordValue', (userAchievements[currentAchievementIndex].recordValue + 1).toString()) : currentAchievementTemplate.condition; - let maxValue; - let currentValue; + let maxValue: number; + let currentValue: number; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations); const relation = userAchievements[currentAchievementIndex].context['relation'] || null; @@ -181,8 +198,8 @@ const assembleAchievementData = async (userAchievements: User_achievement[], use description: currentAchievementTemplate.description, image: getAchievementImageURL(currentAchievementTemplate.image), alternativeText: 'alternativeText', - actionType: currentAchievementTemplate.actionType as achievement_action_type_enum, - achievementType: currentAchievementTemplate.type as achievement_type_enum, + actionType: currentAchievementTemplate.actionType, + achievementType: currentAchievementTemplate.type, achievementState: state, steps: currentAchievementTemplate.stepName ? achievementTemplates diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts index e03b0fb31..137844dff 100644 --- a/common/achievement/metrics.ts +++ b/common/achievement/metrics.ts @@ -3,7 +3,7 @@ import 'reflect-metadata'; import { ActionID } from '../notification/actions'; import { Metric } from './types'; -export const metricByName: Map = new Map(); +const metricByName: Map = new Map(); const metricsByAction: Map = new Map(); export function getMetricsByAction(actionId: ID): Metric[] { diff --git a/common/achievement/template.ts b/common/achievement/template.ts index b284d68c4..438abc578 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -48,7 +48,7 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise templatesByMetric[key]).reduce((all, temp) => [...all, ...temp], 0).length === 0) { logger.debug(`No achievement templates were found in the database for the metrics: ${metricsForAction.map((m) => `${m.metricName}, `)}`); return []; } diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 51046e84d..594104840 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -3,7 +3,7 @@ import 'reflect-metadata'; import { AchievementContextType, RelationTypes } from './types'; import { join } from 'path'; import { prisma } from '../prisma'; -import { Prisma } from '@prisma/client'; +import { Prisma, achievement_template, user_achievement } from '@prisma/client'; import { accessURLForKey } from '../file-bucket'; import { achievement_state } from '../../graphql/types/achievement'; import { User, getUserTypeAndIdForUserId } from '../user'; @@ -104,9 +104,20 @@ export function transformPrismaJson(user: User, json: Prisma.JsonValue): Achieve }; return transformedJson; } - -export function renderAchievementWithContext(userAchievement: User_achievement, achievementContext: AchievementContextType): Achievement_template { - const currentAchievementContext = userAchievement.template as Achievement_template; +export async function getUserAchievementWithTemplate(id: number) { + return await prisma.user_achievement.findUnique({ + where: { id }, + include: { template: true }, + }); +} +type ThenArg = T extends PromiseLike ? U : T; +export type achievement_with_template = ThenArg>; + +export function renderAchievementWithContext( + userAchievement: user_achievement & { template: achievement_template }, + achievementContext: AchievementContextType +): Achievement_template { + const currentAchievementContext = userAchievement.template; const templateKeys = Object.keys(userAchievement.template); templateKeys.forEach((key) => { const updatedElement = @@ -118,7 +129,7 @@ export function renderAchievementWithContext(userAchievement: User_achievement, return currentAchievementContext; } -export function getAchievementState(userAchievements: User_achievement[], currentAchievementIndex: number) { +export function getAchievementState(userAchievements: user_achievement[], currentAchievementIndex: number) { return userAchievements.length === 0 ? achievement_state.INACTIVE : userAchievements[currentAchievementIndex].achievedAt diff --git a/common/notification/actions.ts b/common/notification/actions.ts index d95b92848..c3d42fa3e 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -663,7 +663,6 @@ const _notificationActions = { description: 'Student joined a match meeting', sampleContext: { matchId: '1', - pupil: { firstname: 'Pupil' }, // = matchpartner relation: 'match/1', }, }, diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts index 5c93e39cc..800588361 100644 --- a/graphql/achievement/mutations.ts +++ b/graphql/achievement/mutations.ts @@ -8,29 +8,6 @@ import { prisma } from '../../common/prisma'; @Resolver(() => Achievement) export class MutateAchievementResolver { - @Mutation((returns) => Boolean) - @AuthorizedDeferred(Role.ADMIN, Role.OWNER) - async matchMeetingJoin(@Ctx() context: GraphQLContext, @Arg('matchId') matchId: number) { - const { user } = context; - const match = await prisma.match.findUnique({ where: { id: matchId }, include: { pupil: true, student: true } }); - await hasAccess(context, 'Match', match); - - if (user.studentId) { - await Notification.actionTaken(user, 'student_joined_match_meeting', { - matchId: matchId.toString(), - pupil: match.pupil, - relation: `match/${matchId}`, - }); - } else if (user.pupilId) { - await Notification.actionTaken(user, 'pupil_joined_match_meeting', { - matchId: matchId.toString(), - relation: `match/${matchId}`, - }); - } - - return true; - } - @Mutation((returns) => Boolean) @AuthorizedDeferred(Role.ADMIN, Role.OWNER) async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('subcourseId') subcourseId: number) { diff --git a/graphql/match/mutations.ts b/graphql/match/mutations.ts index d8efb69fb..9c71584ec 100644 --- a/graphql/match/mutations.ts +++ b/graphql/match/mutations.ts @@ -1,6 +1,7 @@ import * as TypeGraphQL from 'type-graphql'; import { Arg, Authorized, Ctx, InputType, Int, Mutation, Resolver } from 'type-graphql'; import * as GraphQLModel from '../generated/models'; +import * as Notification from '../../common/notification'; import { AuthorizedDeferred, hasAccess, Role } from '../authorizations'; import { getMatch, getPupil, getStudent } from '../util'; import { dissolveMatch, reactivateMatch } from '../../common/match/dissolve'; @@ -91,4 +92,26 @@ export class MutateMatchResolver { } throw new AuthenticationError(`User is not allowed to create ad-hoc meeting for match ${matchId}`); } + + @Mutation((returns) => Boolean) + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async matchMeetingJoin(@Ctx() context: GraphQLContext, @Arg('matchId') matchId: number) { + const { user } = context; + const match = await prisma.match.findUnique({ where: { id: matchId }, include: { pupil: true, student: true } }); + await hasAccess(context, 'Match', match); + + if (user.studentId) { + await Notification.actionTaken(user, 'student_joined_match_meeting', { + matchId: matchId.toString(), + relation: `match/${matchId}`, + }); + } else if (user.pupilId) { + await Notification.actionTaken(user, 'pupil_joined_match_meeting', { + matchId: matchId.toString(), + relation: `match/${matchId}`, + }); + } + + return true; + } } diff --git a/graphql/types/achievement.ts b/graphql/types/achievement.ts index 49d993858..4e50ec2e2 100644 --- a/graphql/types/achievement.ts +++ b/graphql/types/achievement.ts @@ -1,5 +1,5 @@ import { ObjectType, Field, Int, registerEnumType } from 'type-graphql'; -import { achievement_action_type_enum, achievement_type_enum } from '../generated'; +import { achievement_type_enum, achievement_action_type_enum } from '@prisma/client'; enum achievement_state { INACTIVE = 'INACTIVE', @@ -11,6 +11,14 @@ registerEnumType(achievement_state, { name: 'achievement_state', }); +registerEnumType(achievement_action_type_enum, { + name: 'achievement_action_type_enum', +}); + +registerEnumType(achievement_type_enum, { + name: 'achievement_type_enum', +}); + @ObjectType() class Achievement { @Field() @@ -32,7 +40,7 @@ class Achievement { alternativeText: string; @Field(() => achievement_action_type_enum, { nullable: true }) - actionType: achievement_action_type_enum; + actionType?: achievement_action_type_enum; @Field(() => achievement_type_enum) achievementType: achievement_type_enum; diff --git a/seed-db.ts b/seed-db.ts index 3d71a3ef0..979a4cfb6 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -27,7 +27,7 @@ import { course_subject_enum as CourseSubject, lecture_appointmenttype_enum as AppointmentType, } from '@prisma/client'; -import { achievement_action_type_enum, achievement_template_for_enum, achievement_type_enum } from './graphql/generated'; +import { achievement_action_type_enum, achievement_template_for_enum, achievement_type_enum } from '@prisma/client'; const logger = getLogger('DevSetup'); From 64f5837004989a1d097236a6a9ed2d2c0b580ebd Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 08:54:21 +0100 Subject: [PATCH 29/58] fix: reduce function for template by metric check --- common/achievement/template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 438abc578..83e0d1093 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -48,7 +48,7 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise templatesByMetric[key]).reduce((all, temp) => [...all, ...temp], 0).length === 0) { + if (templateKeys.map((key) => templatesByMetric[key]).reduce((all, temp) => all.concat(temp), []).length === 0) { logger.debug(`No achievement templates were found in the database for the metrics: ${metricsForAction.map((m) => `${m.metricName}, `)}`); return []; } From 41392eca9d78e23b6350f25b80200499eaad3d31 Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Tue, 23 Jan 2024 09:28:14 +0100 Subject: [PATCH 30/58] Feat: offer course template (#951) * feat: offer course template * fix: refactor course created for all instructors * fix: update achievement context with course * fix: instructors, delete funtions * fix: update with requested changes * fix: update mutation functions for course mutation * fix: check for last achievement --------- Co-authored-by: LomyW <115979086+LomyW@users.noreply.github.com> --- common/achievement/create.ts | 9 ++- common/achievement/metric.ts | 11 +++- common/achievement/types.ts | 4 +- common/achievement/util.ts | 12 ++-- common/courses/states.ts | 29 ++++++++- common/notification/actions.ts | 20 ++++-- graphql/course/mutations.ts | 40 +++++++++++- graphql/subcourse/mutations.ts | 15 ++++- seed-db.ts | 109 +++++++++++++++++++++++++++++++++ 9 files changed, 229 insertions(+), 20 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index dc960a01c..c9c85afb6 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,4 +1,4 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, achievement_type_enum } from '@prisma/client'; import { Achievement_template, achievement_template_for_enum } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; @@ -88,6 +88,10 @@ async function createNextUserAchievement( return; } const nextStepTemplate = templatesForGroup[nextStepIndex]; + const achievedAt = + templatesForGroup.length === nextStepIndex && templatesForGroup[nextStepIndex].type === achievement_type_enum.SEQUENTIAL + ? JSON.stringify(new Date()) + : null; // 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) { @@ -99,13 +103,12 @@ async function createNextUserAchievement( context: context ? context : Prisma.JsonNull, template: { connect: { id: nextStepTemplate.id } }, recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null, + achievedAt: achievedAt, }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); return createdUserAchievement; } - const nextUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex + 1, userId, context); - return nextUserAchievement; } export { getOrCreateUserAchievement, createAchievement }; diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index 3d17e7de3..c2be5c44d 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -72,7 +72,16 @@ const batchOfMetrics = [ return 1; }), - // TODO: add offer course metric listening to 2 actions - screening_success and course_created + /* OFFER COURSE */ + createMetric('student_create_course', ['instructor_course_created'], () => { + return 1; + }), + createMetric('student_submit_course', ['instructor_course_submitted'], () => { + return 1; + }), + createMetric('student_approve_course', ['instructor_course_approved'], () => { + return 1; + }), // TODO: new match metric listening to 2 actions - screening_success and match_requested diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 7b6d9e84e..c4309cd70 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -114,6 +114,6 @@ export type ContextSubcourse = { export type AchievementContextType = { user?: User; - match: ContextMatch[]; - subcourse: ContextSubcourse[]; + match?: ContextMatch[]; + subcourse?: ContextSubcourse[]; }; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 594104840..b29a0163e 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -94,14 +94,14 @@ export async function getBucketContext(userID: string, relation?: string): Promi } export function transformPrismaJson(user: User, json: Prisma.JsonValue): AchievementContextType | null { - if (!json['match'] && !json['subcourse']) { + const keys = Object.keys(json); + if (!keys) { return null; } - const transformedJson: AchievementContextType = { - user: user, - match: json['match'] ? json['match'] : undefined, - subcourse: json['subcourse'] ? json['subcourse'] : undefined, - }; + const transformedJson: AchievementContextType = { user: user }; + keys.forEach((key) => { + transformedJson[key] = json[key]; + }); return transformedJson; } export async function getUserAchievementWithTemplate(id: number) { diff --git a/common/courses/states.ts b/common/courses/states.ts index 2a50c8acc..b68decdd0 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -21,6 +21,7 @@ import { cancelAppointment } from '../appointment/cancel'; import { User, userForStudent } from '../user'; import { addGroupAppointmentsOrganizer } from '../appointment/participants'; import { sendPupilCoursePromotion, sendSubcourseCancelNotifications } from './notifications'; +import * as Notification from '../../common/notification'; const logger = getLogger('Course States'); @@ -70,12 +71,23 @@ export async function allowCourse(course: Course, screeningComment: string | nul // Usually when a new course is created, instructors also create a proper subcourse with it // and then forget to publish it after it was approved. Thus we just publish during approval, // assuming the subcourses are ready: - const subcourses = await prisma.subcourse.findMany({ where: { courseId: course.id } }); + const subcourses = await prisma.subcourse.findMany({ + where: { courseId: course.id }, + include: { subcourse_instructors_student: { select: { student: true } } }, + }); for (const subcourse of subcourses) { if (await canPublish(subcourse)) { await publishSubcourse(subcourse); } } + + const [subcourse] = subcourses; + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }); + }); } export async function denyCourse(course: Course, screeningComment: string | null) { @@ -152,6 +164,16 @@ export async function cancelSubcourse(user: User, subcourse: Subcourse) { } await sendSubcourseCancelNotifications(course, subcourse); logger.info(`Subcourse (${subcourse.id}) was cancelled`); + + const courseInstructors = await prisma.subcourse_instructors_student.findMany({ where: { subcourseId: subcourse.id }, select: { student: true } }); + await prisma.user_achievement.deleteMany({ + where: { + userId: { + in: courseInstructors.map((instructor) => userForStudent(instructor.student).userID), + }, + context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, + }, + }); } /* --------------- Modify Subcourse ------------------- */ @@ -231,5 +253,10 @@ export async function addSubcourseInstructor(user: User | null, subcourse: Subco await addParticipant(newInstructorUser, subcourse.conversationId, subcourse.groupChatType as ChatType); } + const { name } = await prisma.course.findUnique({ where: { id: subcourse.courseId }, select: { name: true } }); + await Notification.actionTaken(userForStudent(newInstructor), 'instructor_course_created', { + courseName: name, + relation: `subcourse/${subcourse.id}`, + }); logger.info(`Student (${newInstructor.id}) was added as an instructor to Subcourse(${subcourse.id}) by User(${user?.userID})`); } diff --git a/common/notification/actions.ts b/common/notification/actions.ts index c3d42fa3e..005e5c587 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -210,10 +210,22 @@ const _notificationActions = { instructor_course_created: { description: 'Instructor / Course created (not yet published)', sampleContext: { - course: { - name: 'Hallo Welt', - description: 'Ein Kurs', - }, + courseName: 'Beispielkurs', + relation: 'subcourse/1', + }, + }, + instructor_course_submitted: { + description: 'Instructor / Course submitted for review', + sampleContext: { + courseName: 'Beispielkurs', + relation: 'subcourse/1', + }, + }, + instructor_course_approved: { + description: 'Instructor / Course approved', + sampleContext: { + courseName: 'Beispielkurs', + relation: 'subcourse/1', }, }, instructor_course_cancelled: { diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index f4943b55f..107e97e5b 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -1,4 +1,4 @@ -import { course_category_enum } from '@prisma/client'; +import { course_category_enum, user_achievement } from '@prisma/client'; import { UserInputError } from 'apollo-server-express'; import { getFile, removeFile } from '../files'; import { getLogger } from '../../common/logger/logger'; @@ -11,12 +11,14 @@ import { GraphQLContext } from '../context'; import * as GraphQLModel from '../generated/models'; import { getCourse, getStudent, getSubcoursesForCourse } from '../util'; import { putFile, DEFAULT_BUCKET } from '../../common/file-bucket'; +import * as Notification from '../../common/notification'; import { course_schooltype_enum as CourseSchooltype, course_subject_enum as CourseSubject, course_coursestate_enum as CourseState } from '../generated'; import { ForbiddenError } from '../error'; import { addCourseInstructor, allowCourse, denyCourse, subcourseOver } from '../../common/courses/states'; import { getCourseImageKey } from '../../common/courses/util'; import { createCourseTag } from '../../common/courses/tags'; +import { userForStudent } from '../../common/user'; @InputType() class PublicCourseCreateInput { @@ -90,6 +92,7 @@ export class MutateCourseResolver { @Arg('course') data: PublicCourseEditInput ): Promise { const course = await getCourse(courseId); + const user = context.user; await hasAccess(context, 'Course', course); if (course.courseState === 'allowed') { @@ -106,6 +109,30 @@ export class MutateCourseResolver { } const result = await prisma.course.update({ data, where: { id: courseId } }); logger.info(`Course (${result.id}) updated by Student (${context.user.studentId})`); + + const subcourse = await prisma.subcourse.findFirst({ + where: { courseId: courseId }, + }); + const usersSubcourseAchievements = await prisma.user_achievement.findMany({ + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, userId: user.userID }, + include: { template: true }, + }); + const subcourseAchievements = await Promise.all( + usersSubcourseAchievements.map(async (usersSubcourseAchievement) => { + return await prisma.user_achievement.findMany({ + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, templateId: usersSubcourseAchievement.template.id }, + }); + }) + ); + subcourseAchievements.flat().forEach(async (achievement) => { + const { context } = achievement; + context['courseName'] = result.name; + await prisma.user_achievement.update({ + where: { id: achievement.id }, + data: { context }, + }); + }); + return result; } @@ -193,6 +220,17 @@ export class MutateCourseResolver { await hasAccess(context, 'Course', course); await prisma.course.update({ data: { courseState: 'submitted' }, where: { id: courseId } }); logger.info(`Course (${courseId}) submitted by Student (${context.user.studentId})`); + + const subcourse = await prisma.subcourse.findFirst({ + where: { courseId: courseId }, + include: { subcourse_instructors_student: { select: { student: true } } }, + }); + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }); + }); return true; } diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 8b73e2df1..7351b4eca 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -17,6 +17,7 @@ import { getCourse, getPupil, getStudent, getSubcourse } from '../util'; import { chat_type } from '../generated'; import { markConversationAsReadOnly, removeParticipantFromCourseChat } from '../../common/chat/conversation'; import { sendPupilCoursePromotion } from '../../common/courses/notifications'; +import * as Notification from '../../common/notification'; const logger = getLogger('MutateCourseResolver'); @@ -99,6 +100,10 @@ export class MutateSubcourseResolver { const student = await getSessionStudent(context, studentId); await prisma.subcourse_instructors_student.create({ data: { subcourseId: result.id, studentId: student.id } }); + await Notification.actionTaken(userForStudent(student), 'instructor_course_created', { + courseName: course.name, + relation: `subcourse/${result.id}`, + }); logger.info(`Subcourse(${result.id}) was created for Course(${courseId}) and Student(${student.id})`); return result; } @@ -110,7 +115,6 @@ export class MutateSubcourseResolver { @Arg('subcourseId') subcourseId: number, @Arg('studentId') studentId: number ): Promise { - const { user } = context; const subcourse = await getSubcourse(subcourseId); await hasAccess(context, 'Subcourse', subcourse); @@ -127,7 +131,6 @@ export class MutateSubcourseResolver { @Arg('subcourseId') subcourseId: number, @Arg('studentId') studentId: number ): Promise { - const { user } = context; const subcourse = await getSubcourse(subcourseId); await hasAccess(context, 'Subcourse', subcourse); const instructorToBeRemoved = await getStudent(studentId); @@ -138,6 +141,14 @@ export class MutateSubcourseResolver { await removeParticipantFromCourseChat(instructorUser, subcourse.conversationId); } logger.info(`Student(${studentId}) was deleted from Subcourse(${subcourseId}) by User(${context.user.userID})`); + + await prisma.user_achievement.deleteMany({ + where: { + userId: `student/${studentId}`, + context: { path: ['relation'], equals: `subcourse/${subcourseId}` }, + }, + }); + return true; } diff --git a/seed-db.ts b/seed-db.ts index 979a4cfb6..92257f55c 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1297,6 +1297,115 @@ void (async function setupDevDB() { }, }); + // STUDENT OFFER COURSE + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_create_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 1, + stepName: 'Kurs entwerfen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Vermittle Wissen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs anlegen', + actionRedirectLink: '/create-course', + actionType: achievement_action_type_enum.Action, + condition: 'student_create_course_events > 0', + conditionDataAggregations: { + student_create_course_events: { + metric: 'student_create_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_submit_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 2, + stepName: 'Kurs zur Prüfung freigeben', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs freigeben', + actionRedirectLink: '/single-course/{{courseId}}', + actionType: achievement_action_type_enum.Action, + condition: 'student_submit_course_events > 0', + conditionDataAggregations: { + student_submit_course_events: { + metric: 'student_submit_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_approve_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 3, + stepName: 'Freigabe erhalten', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs absagen', + actionRedirectLink: '/single-course/{{courseId}}', + actionType: achievement_action_type_enum.Wait, + condition: 'student_approve_course_events > 0', + conditionDataAggregations: { + student_approve_course_events: { + metric: 'student_approve_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_approve_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 4, + stepName: 'Kurs erstellt!', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'student_approve_course_events > 0', + conditionDataAggregations: { + student_approve_course_events: { + metric: 'student_approve_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + // Add Instructors and Participants after adding Lectures, so that they are also added to the lectures: await addSubcourseInstructor(null, subcourse1, student1); await addSubcourseInstructor(null, subcourse1, student2); From ccdd4a2ab32d8d445a9951cce1eccbd4d8e723c2 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 11:28:02 +0100 Subject: [PATCH 31/58] fix: check for subcourses before course submit --- graphql/course/mutations.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index 107e97e5b..c1388d11c 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -225,12 +225,14 @@ export class MutateCourseResolver { where: { courseId: courseId }, include: { subcourse_instructors_student: { select: { student: true } } }, }); - subcourse.subcourse_instructors_student.forEach(async (instructor) => { - await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { - courseName: course.name, - relation: `subcourse/${subcourse.id}`, + if (subcourse && subcourse.subcourse_instructors_student) { + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }); }); - }); + } return true; } From 762f6889d89346a049c8e010fd83c689771bf7a9 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 11:41:08 +0100 Subject: [PATCH 32/58] fix: create course achievement --- common/courses/states.ts | 1 + common/notification/actions.ts | 2 ++ graphql/course/mutations.ts | 1 + seed-db.ts | 4 ++-- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/courses/states.ts b/common/courses/states.ts index b68decdd0..c95604fe0 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -85,6 +85,7 @@ export async function allowCourse(course: Course, screeningComment: string | nul subcourse.subcourse_instructors_student.forEach(async (instructor) => { await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { courseName: course.name, + subcourseId: subcourse.id.toString(), relation: `subcourse/${subcourse.id}`, }); }); diff --git a/common/notification/actions.ts b/common/notification/actions.ts index 005e5c587..422cfac3b 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -218,6 +218,7 @@ const _notificationActions = { description: 'Instructor / Course submitted for review', sampleContext: { courseName: 'Beispielkurs', + subcourseId: '1', relation: 'subcourse/1', }, }, @@ -225,6 +226,7 @@ const _notificationActions = { description: 'Instructor / Course approved', sampleContext: { courseName: 'Beispielkurs', + subcourseId: '1', relation: 'subcourse/1', }, }, diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index c1388d11c..fcfe3198a 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -229,6 +229,7 @@ export class MutateCourseResolver { subcourse.subcourse_instructors_student.forEach(async (instructor) => { await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { courseName: course.name, + subcourseId: subcourse.id.toString(), relation: `subcourse/${subcourse.id}`, }); }); diff --git a/seed-db.ts b/seed-db.ts index 92257f55c..9bd0baa1e 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1339,7 +1339,7 @@ void (async function setupDevDB() { image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', achievedImage: '', actionName: 'Kurs freigeben', - actionRedirectLink: '/single-course/{{courseId}}', + actionRedirectLink: '/single-course/{{subcourseId}}', actionType: achievement_action_type_enum.Action, condition: 'student_submit_course_events > 0', conditionDataAggregations: { @@ -1366,7 +1366,7 @@ void (async function setupDevDB() { image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', achievedImage: '', actionName: 'Kurs absagen', - actionRedirectLink: '/single-course/{{courseId}}', + actionRedirectLink: '/single-course/{{subcourseId}}', actionType: achievement_action_type_enum.Wait, condition: 'student_approve_course_events > 0', conditionDataAggregations: { From 66f9248bd586c67685eba623fe1020d030a42021 Mon Sep 17 00:00:00 2001 From: LomyW Date: Tue, 23 Jan 2024 13:36:51 +0100 Subject: [PATCH 33/58] some bugs --- common/achievement/create.ts | 4 +--- common/achievement/get.ts | 4 ---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index c9c85afb6..1a08ceadf 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -89,9 +89,7 @@ async function createNextUserAchievement( } const nextStepTemplate = templatesForGroup[nextStepIndex]; const achievedAt = - templatesForGroup.length === nextStepIndex && templatesForGroup[nextStepIndex].type === achievement_type_enum.SEQUENTIAL - ? JSON.stringify(new Date()) - : null; + templatesForGroup.length - 1 === nextStepIndex && templatesForGroup[nextStepIndex].type === achievement_type_enum.SEQUENTIAL ? new Date() : null; // 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) { diff --git a/common/achievement/get.ts b/common/achievement/get.ts index e819b99cb..159fc5b8e 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -181,10 +181,6 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ }) .reduce((a, b) => a + b, 0); } else { - const achievementTemplates = await prisma.achievement_template.findMany({ - where: { group: currentAchievementTemplate.group, isActive: true }, - orderBy: { groupOrder: 'asc' }, - }); currentValue = currentAchievementIndex; maxValue = achievementTemplates.length - 1; } From e2f6c12fd13d2d11beec70825feddfdeaa893b07 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 14:08:29 +0100 Subject: [PATCH 34/58] fix: display right next step achievements --- common/achievement/get.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 159fc5b8e..79920be9d 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -29,7 +29,7 @@ const getAchievementById = async (user: User, achievementId: number): Promise => { const userAchievements = await prisma.user_achievement.findMany({ - where: { userId: user.userID, isSeen: false, template: { type: achievement_type_enum.SEQUENTIAL } }, + where: { userId: user.userID, template: { type: achievement_type_enum.SEQUENTIAL } }, include: { template: true }, }); const userAchievementGroups: { [groupRelation: string]: achievements_with_template } = {}; @@ -41,6 +41,10 @@ const getNextStepAchievements = async (user: User): Promise => { } userAchievementGroups[key].push(ua); }); + Object.keys(userAchievementGroups).forEach((groupName) => { + const group = userAchievementGroups[groupName]; + group[group.length - 1].achievedAt && delete userAchievementGroups[groupName]; + }); const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); return achievements; }; From a54293d21ba40eda6c8e5edb4bf59596585df9fb Mon Sep 17 00:00:00 2001 From: LomyW Date: Tue, 23 Jan 2024 14:55:32 +0100 Subject: [PATCH 35/58] fix seed data --- common/achievement/get.ts | 4 +- seed-db.ts | 98 +++++++++++++++++++++++++++++++++------ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 79920be9d..a86de2f19 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -42,7 +42,7 @@ const getNextStepAchievements = async (user: User): Promise => { userAchievementGroups[key].push(ua); }); Object.keys(userAchievementGroups).forEach((groupName) => { - const group = userAchievementGroups[groupName]; + const group = userAchievementGroups[groupName].sort((a, b) => a.groupOrder - b.groupOrder); group[group.length - 1].achievedAt && delete userAchievementGroups[groupName]; }); const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); @@ -222,7 +222,7 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template progressDescription: userAchievements[currentAchievementIndex].achievedAt ? 'Hurra! alle Schritte wurden abgeschlossen' - : `Noch ${userAchievements.length - userAchievements.length} Schritte bis zum Abschluss`, + : `Noch ${maxValue - currentValue} Schritte bis zum Abschluss`, actionName: currentAchievementTemplate.actionName, actionRedirectLink: currentAchievementTemplate.actionRedirectLink, }; diff --git a/seed-db.ts b/seed-db.ts index 9bd0baa1e..5925bdf5a 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -826,7 +826,7 @@ void (async function setupDevDB() { image: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_3.png', achievedImage: '', actionName: 'Zeugnis einreichen', - actionRedirectLink: 'mailto:fz@lern-fair.de', + actionRedirectLink: '/certificate-of-conduct', actionType: achievement_action_type_enum.Action, condition: 'student_coc_success_events > 0', conditionDataAggregations: { student_coc_success_events: { metric: 'student_onboarding_coc_success', aggregator: 'count' } }, @@ -963,7 +963,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 0', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 1, + }, }, isActive: true, }, @@ -987,7 +993,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 2', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 3, + }, }, isActive: true, }, @@ -1011,7 +1023,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 4', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 5, + }, }, isActive: true, }, @@ -1035,7 +1053,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 9', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 10, + }, }, isActive: true, }, @@ -1059,7 +1083,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 14', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 15, + }, }, isActive: true, }, @@ -1083,7 +1113,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'student_match_appointments_count > 24', conditionDataAggregations: { - student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + student_match_appointments_count: { + metric: 'student_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 25, + }, }, isActive: true, }, @@ -1109,7 +1145,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 0', conditionDataAggregations: { - pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + pupil_match_appointments_count: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 1, + }, }, isActive: true, }, @@ -1133,7 +1175,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 2', conditionDataAggregations: { - pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + pupil_match_appointments_count: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 3, + }, }, isActive: true, }, @@ -1157,7 +1205,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 4', conditionDataAggregations: { - pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + pupil_match_appointments_count: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 5, + }, }, isActive: true, }, @@ -1181,7 +1235,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 9', conditionDataAggregations: { - student_conducted_match_appointments: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + student_conducted_match_appointments: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 10, + }, }, isActive: true, }, @@ -1205,7 +1265,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 14', conditionDataAggregations: { - pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + pupil_match_appointments_count: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 15, + }, }, isActive: true, }, @@ -1229,7 +1295,13 @@ void (async function setupDevDB() { achievedText: 'Juhu! Dieser Text muss noch geliefert werden', condition: 'pupil_match_appointments_count > 24', conditionDataAggregations: { - pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + pupil_match_appointments_count: { + metric: 'pupil_conducted_match_appointment', + aggregator: 'count', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 25, + }, }, isActive: true, }, From dfe7ed3d17baecd903bf3bf9d86f5675c9dc171a Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 16:02:34 +0100 Subject: [PATCH 36/58] fix: add information to action --- common/courses/states.ts | 1 + common/notification/actions.ts | 1 + graphql/subcourse/mutations.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/common/courses/states.ts b/common/courses/states.ts index c95604fe0..3f3ef4c22 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -257,6 +257,7 @@ export async function addSubcourseInstructor(user: User | null, subcourse: Subco const { name } = await prisma.course.findUnique({ where: { id: subcourse.courseId }, select: { name: true } }); await Notification.actionTaken(userForStudent(newInstructor), 'instructor_course_created', { courseName: name, + subcourseId: subcourse.id.toString(), relation: `subcourse/${subcourse.id}`, }); logger.info(`Student (${newInstructor.id}) was added as an instructor to Subcourse(${subcourse.id}) by User(${user?.userID})`); diff --git a/common/notification/actions.ts b/common/notification/actions.ts index 422cfac3b..ae5f5b4a1 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -211,6 +211,7 @@ const _notificationActions = { description: 'Instructor / Course created (not yet published)', sampleContext: { courseName: 'Beispielkurs', + subcourseId: '1', relation: 'subcourse/1', }, }, diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 7351b4eca..64a7eef42 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -102,6 +102,7 @@ export class MutateSubcourseResolver { await Notification.actionTaken(userForStudent(student), 'instructor_course_created', { courseName: course.name, + subcourseId: result.id.toString(), relation: `subcourse/${result.id}`, }); logger.info(`Subcourse(${result.id}) was created for Course(${courseId}) and Student(${student.id})`); From 4984c60acf6608d637a0533c325e93162e908f64 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 16:04:08 +0100 Subject: [PATCH 37/58] fix: update seed file --- seed-db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed-db.ts b/seed-db.ts index 5925bdf5a..2dd49da68 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1384,7 +1384,7 @@ void (async function setupDevDB() { image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', achievedImage: '', actionName: 'Kurs anlegen', - actionRedirectLink: '/create-course', + actionRedirectLink: '/create-course/{{subcourseId}}', actionType: achievement_action_type_enum.Action, condition: 'student_create_course_events > 0', conditionDataAggregations: { From e647477c7750fd2528c52c1fd75db35b44009e71 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 23 Jan 2024 16:07:08 +0100 Subject: [PATCH 38/58] fix: revert redirect link --- seed-db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed-db.ts b/seed-db.ts index 2dd49da68..5925bdf5a 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1384,7 +1384,7 @@ void (async function setupDevDB() { image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', achievedImage: '', actionName: 'Kurs anlegen', - actionRedirectLink: '/create-course/{{subcourseId}}', + actionRedirectLink: '/create-course', actionType: achievement_action_type_enum.Action, condition: 'student_create_course_events > 0', conditionDataAggregations: { From 7b435fdf32b34c00f320aa60a6361765bd012875 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 24 Jan 2024 09:32:34 +0100 Subject: [PATCH 39/58] fix: lecture filtering --- common/achievement/bucket.ts | 13 +++++++++++-- common/achievement/types.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 1f74720d5..1ef8dcd96 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,5 +1,5 @@ import moment from 'moment'; -import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket, ContextMatch, ContextSubcourse } from './types'; +import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket, ContextMatch, ContextSubcourse, ContextLecture } from './types'; type BucketCreatorDefs = Record; @@ -7,8 +7,17 @@ function createLectureBuckets(data: T if (!data.lecture || data.lecture.length === 0) { return []; } + data.lecture.sort((a, b) => a.start.getTime() - b.start.getTime()); + const filteredLectures: ContextLecture[] = data.lecture.filter((lecture, index, array) => { + if (index === 0) { + return true; + } + const previousEndTime = new Date(array[index - 1].start.getTime() + array[index - 1].duration * 60000); + return lecture.start >= previousEndTime; + }); + // const relation = context.type === ('match' || 'subcourse') ? `${context.type}/${match['id']}` : null; - const buckets: TimeBucket[] = data.lecture.map((lecture) => ({ + const buckets: TimeBucket[] = filteredLectures.map((lecture) => ({ kind: 'time', relation: data.relation, // TODO: maybe it's possible to pass the 10 minutes as a parameter to the bucketCreatorDefs diff --git a/common/achievement/types.ts b/common/achievement/types.ts index c4309cd70..9ee9d9aec 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -100,7 +100,7 @@ export type EvaluationResult = { // match and subcourse are relation types to point to a specific match or subcourse, whereas global_match and global_subcourse are used to point to all matches/subcourses of a user export type RelationTypes = 'match' | 'subcourse' | 'global_match' | 'global_subcourse'; // match_all, subcourse_all, all -type ContextLecture = Pick; +export type ContextLecture = Pick; export type ContextMatch = { id: number; relation: string | null; // will be null if searching for all matches From c794919ccd89ef9adda3757e9b77f144bcdbdb0e Mon Sep 17 00:00:00 2001 From: LomyW Date: Wed, 24 Jan 2024 09:49:44 +0100 Subject: [PATCH 40/58] fix final notification for sequentials --- common/achievement/index.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 14420a0a3..81f2b79cb 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -3,7 +3,7 @@ import { User } from '../user'; import { sortActionTemplatesToGroups } from './util'; import { getLogger } from '../logger/logger'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; -import { getTemplatesByMetrics } from './template'; +import { getAchievementTemplates, getTemplatesByMetrics, TemplateSelectEnum } from './template'; import { evaluateAchievement } from './evaluate'; import { AchievementToCheck, ActionEvent, ConditionDataAggregations, UserAchievementTemplate } from './types'; import { createAchievement, getOrCreateUserAchievement } from './create'; @@ -171,8 +171,21 @@ async function rewardUser(evaluationResult: number, userAch select: { id: true, userId: true, achievedAt: true, template: true }, }); - await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { - achievement: { name: updatedAchievement.template.name, id: updatedAchievement.id.toString() }, - }); + const { type, group, groupOrder } = updatedAchievement.template; + + if (type === achievement_type_enum.SEQUENTIAL) { + const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + const groupTemplates = templatesByGroup.get(group); + const lastTemplate = groupTemplates[groupTemplates.length - 2]; + if (groupOrder === lastTemplate.groupOrder) { + await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { + achievement: { name: updatedAchievement.template.name, id: updatedAchievement.id.toString() }, + }); + } + } else { + await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { + achievement: { name: updatedAchievement.template.name, id: updatedAchievement.id.toString() }, + }); + } return updatedAchievement; } From 8f9afec807f56f1fcac4f2b7e641c16f38e274d3 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 24 Jan 2024 15:00:02 +0100 Subject: [PATCH 41/58] changes requested --- common/achievement/bucket.ts | 2 +- common/achievement/get.ts | 4 ++-- seed-db.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index 1ef8dcd96..dcea1607f 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -22,7 +22,7 @@ function createLectureBuckets(data: T relation: data.relation, // TODO: maybe it's possible to pass the 10 minutes as a parameter to the bucketCreatorDefs startTime: moment(lecture.start).subtract(10, 'minutes').toDate(), - endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(), + endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(5, 'minutes').toDate(), })); return buckets; } diff --git a/common/achievement/get.ts b/common/achievement/get.ts index a86de2f19..150b5444a 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -221,8 +221,8 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ isNewAchievement: isNewAchievement, // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template progressDescription: userAchievements[currentAchievementIndex].achievedAt - ? 'Hurra! alle Schritte wurden abgeschlossen' - : `Noch ${maxValue - currentValue} Schritte bis zum Abschluss`, + ? 'Hurra! alle Termin(e) wurden abgeschlossen' + : `Noch ${maxValue - currentValue} Termin(e) bis zum Abschluss`, actionName: currentAchievementTemplate.actionName, actionRedirectLink: currentAchievementTemplate.actionRedirectLink, }; diff --git a/seed-db.ts b/seed-db.ts index 5925bdf5a..48d6ec916 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1437,8 +1437,8 @@ void (async function setupDevDB() { 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', achievedImage: '', - actionName: 'Kurs absagen', - actionRedirectLink: '/single-course/{{subcourseId}}', + actionName: null, + actionRedirectLink: null, actionType: achievement_action_type_enum.Wait, condition: 'student_approve_course_events > 0', conditionDataAggregations: { From 8f879da62487abd07f16a0adbf8a86fd57e8fdc5 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Thu, 25 Jan 2024 09:32:19 +0100 Subject: [PATCH 42/58] iterate over all subcourses for update --- common/courses/states.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/common/courses/states.ts b/common/courses/states.ts index 3f3ef4c22..feb412517 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -81,14 +81,15 @@ export async function allowCourse(course: Course, screeningComment: string | nul } } - const [subcourse] = subcourses; - subcourse.subcourse_instructors_student.forEach(async (instructor) => { - await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { - courseName: course.name, - subcourseId: subcourse.id.toString(), - relation: `subcourse/${subcourse.id}`, + for (const subcourse of subcourses) { + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { + courseName: course.name, + subcourseId: subcourse.id.toString(), + relation: `subcourse/${subcourse.id}`, + }); }); - }); + } } export async function denyCourse(course: Course, screeningComment: string | null) { From 9462e8d5bd93402c681b2191286ac8d9661fd2ae Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Thu, 25 Jan 2024 09:48:23 +0100 Subject: [PATCH 43/58] Use relation for identification (#965) fix: only use template ID & relation to identify user_achievement --- common/achievement/create.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 1a08ceadf..73369e88b 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -6,24 +6,24 @@ import { TemplateSelectEnum, getAchievementTemplates } from './template'; import tracer from '../logger/tracing'; import { AchievementToCheck } from './types'; -async function findUserAchievement( +function createRelationContextFilter(context: SpecificNotificationContext): Prisma.JsonFilter { + if (context.relation) { + return { path: ['relation'], equals: context.relation }; + } + return { path: ['relation'], equals: Prisma.AnyNull }; +} + +export async function findUserAchievement( templateId: number, userId: string, context: SpecificNotificationContext ): Promise { - const keys = context ? Object.keys(context) : []; + const contextFilter = createRelationContextFilter(context); const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, userId, - AND: keys.map((key) => { - return { - context: { - path: key, - equals: context[key], - }, - }; - }), + context: contextFilter, }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); @@ -51,21 +51,14 @@ async function _createAchievement(currentTemplate: Achievem const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); - const keys = Object.keys(context); + const contextFilter = createRelationContextFilter(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { group: currentTemplate.group, }, userId, - AND: keys.map((key) => { - return { - context: { - path: key, - equals: context[key], - }, - }; - }), + context: contextFilter, }, orderBy: { template: { groupOrder: 'asc' } }, }); From 89fbfceffeb81d5835b8506c49c944cd109ddc21 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 25 Jan 2024 13:08:01 +0100 Subject: [PATCH 44/58] fix: requested changes --- common/achievement/create.ts | 27 ++++++++++++++++--------- common/achievement/index.ts | 6 ++++++ common/achievement/template.ts | 11 +++++------ common/achievement/types.ts | 20 ++++++++++++++----- common/achievement/util.ts | 26 +++++++++--------------- common/courses/states.ts | 23 ++++++++++----------- common/notification/actions.ts | 13 +----------- graphql/achievement/mutations.ts | 34 -------------------------------- graphql/course/mutations.ts | 7 +++---- graphql/index.ts | 2 -- graphql/match/mutations.ts | 2 -- graphql/subcourse/mutations.ts | 25 ++++++++++++++++++++++- graphql/user/fields.ts | 14 +------------ 13 files changed, 94 insertions(+), 116 deletions(-) delete mode 100644 graphql/achievement/mutations.ts diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 73369e88b..a84116cdf 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,5 +1,4 @@ -import { Prisma, achievement_type_enum } from '@prisma/client'; -import { Achievement_template, achievement_template_for_enum } from '../../graphql/generated'; +import { Prisma, achievement_template, achievement_template_for_enum, achievement_type_enum } from '@prisma/client'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; @@ -18,12 +17,17 @@ export async function findUserAchievement( userId: string, context: SpecificNotificationContext ): Promise { - const contextFilter = createRelationContextFilter(context); + const contextHasRelation = context && Object.keys(context).includes('relation'); const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, userId, - context: contextFilter, + AND: contextHasRelation && { + context: { + path: ['relation'], + equals: context['relation'], + }, + }, }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); @@ -31,7 +35,7 @@ export async function findUserAchievement( } async function getOrCreateUserAchievement( - template: Achievement_template, + template: achievement_template, userId: string, context: SpecificNotificationContext ): Promise { @@ -47,18 +51,23 @@ async function getOrCreateUserAchievement( } const createAchievement = tracer.wrap('achievement.createAchievement', _createAchievement); -async function _createAchievement(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext) { +async function _createAchievement(currentTemplate: achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); - const contextFilter = createRelationContextFilter(context); + const contextHasRelation = context && Object.keys(context).includes('relation'); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { group: currentTemplate.group, }, userId, - context: contextFilter, + AND: contextHasRelation && { + context: { + path: ['relation'], + equals: context['relation'], + }, + }, }, orderBy: { template: { groupOrder: 'asc' } }, }); @@ -72,7 +81,7 @@ async function _createAchievement(currentTemplate: Achievem } async function createNextUserAchievement( - templatesForGroup: Achievement_template[], + templatesForGroup: achievement_template[], nextStepIndex: number, userId: string, context: SpecificNotificationContext diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 81f2b79cb..73202ab60 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -176,6 +176,12 @@ async function rewardUser(evaluationResult: number, userAch if (type === achievement_type_enum.SEQUENTIAL) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const groupTemplates = templatesByGroup.get(group); + /** + * Templates are evaluated in sequence, starting from the first and progressing towards the last. + * The @param lastTemplate requiring evaluation is the second-to-last one in the group. + * The final template in the sequence serves solely to display information about the sequential achievement being rewarded. + * Before generating the reward achievement for a sequential achievement, the system checks if the last achievement to be evaluated has been achieved. + */ const lastTemplate = groupTemplates[groupTemplates.length - 2]; if (groupOrder === lastTemplate.groupOrder) { await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 83e0d1093..8ad188168 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -1,9 +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 { prisma } from '../prisma'; import { Metric } from './types'; +import { achievement_template } from '@prisma/client'; const logger = getLogger('Achievement Template'); @@ -13,9 +13,9 @@ export enum TemplateSelectEnum { } // string == metricId, group -const achievementTemplates: Map> = new Map(); +const achievementTemplates: Map> = new Map(); -async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { +async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { if (!achievementTemplates.has(select)) { achievementTemplates.set(select, new Map()); @@ -47,12 +47,11 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise templatesByMetric[key]).reduce((all, temp) => all.concat(temp), []).length === 0) { + if (Array.from(templatesByMetric.values()).reduce((all, temp) => all.concat(temp), []).length === 0) { logger.debug(`No achievement templates were found in the database for the metrics: ${metricsForAction.map((m) => `${m.metricName}, `)}`); return []; } - let templatesForAction: Achievement_template[] = []; + let templatesForAction: achievement_template[] = []; if (!metricsForAction || !templatesByMetric) { return []; } diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 9ee9d9aec..5b72315ae 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,10 +1,18 @@ -import { Achievement_event, Achievement_template, Lecture, User_achievement } from '../../graphql/generated'; +import { achievement_event, achievement_template, lecture } from '@prisma/client'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { User } from '../user'; +import { prisma } from '../prisma'; // type ActionIDUnion = A[number]; // formula: FormulaFunction>; +async function getUserAchievementWithTemplate(id: number) { + return await prisma.user_achievement.findUnique({ + where: { id }, + include: { template: true }, + }); +} + export type Metric = { metricName: string; onActions: ActionID[]; @@ -35,7 +43,7 @@ export type TimeBucket = { export type Bucket = DefaultBucket | TimeBucket; export type BucketEvents = Bucket & { - events: Achievement_event[]; + events: achievement_event[]; }; export type BucketEventsWithAggr = BucketEvents & { @@ -79,7 +87,7 @@ export type UserAchievementTemplate = { userId: string; achievedAt: Date; context: UserAchievementContext; - template: Achievement_template; + template: achievement_template; recordValue?: number; }; @@ -90,7 +98,9 @@ export type ActionEvent = { context: SpecificNotificationContext; }; -export type AchievementToCheck = Pick; +type ThenArg = T extends PromiseLike ? U : T; +export type achievement_with_template = ThenArg>; +export type AchievementToCheck = Pick; export type EvaluationResult = { conditionIsMet: boolean; @@ -100,7 +110,7 @@ export type EvaluationResult = { // match and subcourse are relation types to point to a specific match or subcourse, whereas global_match and global_subcourse are used to point to all matches/subcourses of a user export type RelationTypes = 'match' | 'subcourse' | 'global_match' | 'global_subcourse'; // match_all, subcourse_all, all -export type ContextLecture = Pick; +export type ContextLecture = Pick; export type ContextMatch = { id: number; relation: string | null; // will be null if searching for all matches diff --git a/common/achievement/util.ts b/common/achievement/util.ts index b29a0163e..4a23d1c8e 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -1,15 +1,14 @@ import 'reflect-metadata'; // ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html -import { AchievementContextType, RelationTypes } from './types'; import { join } from 'path'; import { prisma } from '../prisma'; import { Prisma, achievement_template, user_achievement } from '@prisma/client'; import { accessURLForKey } from '../file-bucket'; import { achievement_state } from '../../graphql/types/achievement'; import { User, getUserTypeAndIdForUserId } from '../user'; -import { Achievement_template, User_achievement } from '../../graphql/generated'; import { renderTemplate } from '../../utils/helpers'; import { getLogger } from '../logger/logger'; +import { RelationTypes, AchievementContextType } from './types'; const logger = getLogger('Achievement'); @@ -94,29 +93,22 @@ export async function getBucketContext(userID: string, relation?: string): Promi } export function transformPrismaJson(user: User, json: Prisma.JsonValue): AchievementContextType | null { - const keys = Object.keys(json); - if (!keys) { - return null; - } const transformedJson: AchievementContextType = { user: user }; + if (json['relation']) { + const [relationType, relationId] = getRelationTypeAndId(json['relation']); + transformedJson[`${relationType}Id`] = relationId; + } + const keys = Object.keys(json) || []; keys.forEach((key) => { transformedJson[key] = json[key]; }); return transformedJson; } -export async function getUserAchievementWithTemplate(id: number) { - return await prisma.user_achievement.findUnique({ - where: { id }, - include: { template: true }, - }); -} -type ThenArg = T extends PromiseLike ? U : T; -export type achievement_with_template = ThenArg>; export function renderAchievementWithContext( userAchievement: user_achievement & { template: achievement_template }, achievementContext: AchievementContextType -): Achievement_template { +): achievement_template { const currentAchievementContext = userAchievement.template; const templateKeys = Object.keys(userAchievement.template); templateKeys.forEach((key) => { @@ -137,8 +129,8 @@ export function getAchievementState(userAchievements: user_achievement[], curren : achievement_state.ACTIVE; } -export function sortActionTemplatesToGroups(templatesForAction: Achievement_template[]) { - const templatesByGroups: Map = new Map(); +export function sortActionTemplatesToGroups(templatesForAction: achievement_template[]) { + const templatesByGroups: Map = new Map(); for (const template of templatesForAction) { if (!templatesByGroups.has(template.group)) { templatesByGroups.set(template.group, []); diff --git a/common/courses/states.ts b/common/courses/states.ts index feb412517..bab5b54d2 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -73,7 +73,7 @@ export async function allowCourse(course: Course, screeningComment: string | nul // assuming the subcourses are ready: const subcourses = await prisma.subcourse.findMany({ where: { courseId: course.id }, - include: { subcourse_instructors_student: { select: { student: true } } }, + include: { subcourse_instructors_student: { select: { student: true, subcourseId: true } } }, }); for (const subcourse of subcourses) { if (await canPublish(subcourse)) { @@ -81,15 +81,17 @@ export async function allowCourse(course: Course, screeningComment: string | nul } } - for (const subcourse of subcourses) { - subcourse.subcourse_instructors_student.forEach(async (instructor) => { - await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { - courseName: course.name, - subcourseId: subcourse.id.toString(), - relation: `subcourse/${subcourse.id}`, - }); - }); - } + await Promise.all( + subcourses + .map((subcourse) => subcourse.subcourse_instructors_student) + .flat() + .map(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { + courseName: course.name, + relation: `subcourse/${instructor.subcourseId}`, + }); + }) + ); } export async function denyCourse(course: Course, screeningComment: string | null) { @@ -258,7 +260,6 @@ export async function addSubcourseInstructor(user: User | null, subcourse: Subco const { name } = await prisma.course.findUnique({ where: { id: subcourse.courseId }, select: { name: true } }); await Notification.actionTaken(userForStudent(newInstructor), 'instructor_course_created', { courseName: name, - subcourseId: subcourse.id.toString(), relation: `subcourse/${subcourse.id}`, }); logger.info(`Student (${newInstructor.id}) was added as an instructor to Subcourse(${subcourse.id}) by User(${user?.userID})`); diff --git a/common/notification/actions.ts b/common/notification/actions.ts index ae5f5b4a1..99bcbff3a 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -211,7 +211,6 @@ const _notificationActions = { description: 'Instructor / Course created (not yet published)', sampleContext: { courseName: 'Beispielkurs', - subcourseId: '1', relation: 'subcourse/1', }, }, @@ -219,7 +218,6 @@ const _notificationActions = { description: 'Instructor / Course submitted for review', sampleContext: { courseName: 'Beispielkurs', - subcourseId: '1', relation: 'subcourse/1', }, }, @@ -227,7 +225,6 @@ const _notificationActions = { description: 'Instructor / Course approved', sampleContext: { courseName: 'Beispielkurs', - subcourseId: '1', relation: 'subcourse/1', }, }, @@ -525,13 +522,6 @@ const _notificationActions = { matchId: '1', }, }, - student_add_appointments_match: { - description: 'Student / Match Appointments Added', - sampleContext: { - student: sampleUser, - matchId: '1', - }, - }, pupil_decline_appointment_group: { description: 'Instructor / Group Appointment declined by Participant', sampleContext: { @@ -677,7 +667,6 @@ const _notificationActions = { student_joined_match_meeting: { description: 'Student joined a match meeting', sampleContext: { - matchId: '1', relation: 'match/1', }, }, @@ -691,7 +680,7 @@ const _notificationActions = { pupil_joined_match_meeting: { description: 'Pupil joined a match meeting', sampleContext: { - matchId: '1', + relation: 'match/1', }, }, pupil_joined_subcourse_meeting: { diff --git a/graphql/achievement/mutations.ts b/graphql/achievement/mutations.ts deleted file mode 100644 index 800588361..000000000 --- a/graphql/achievement/mutations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Arg, Authorized, Ctx, Mutation, Resolver } from 'type-graphql'; -import { User_achievement as Achievement } from '../generated'; -import { Role } from '../roles'; -import * as Notification from '../../common/notification'; -import { GraphQLContext } from '../context'; -import { AuthorizedDeferred, hasAccess } from '../authorizations'; -import { prisma } from '../../common/prisma'; - -@Resolver(() => Achievement) -export class MutateAchievementResolver { - @Mutation((returns) => Boolean) - @AuthorizedDeferred(Role.ADMIN, Role.OWNER) - async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('subcourseId') subcourseId: number) { - const { user } = context; - const subcourse = await prisma.subcourse.findUnique({ where: { id: subcourseId }, include: { course: true, lecture: true } }); - await hasAccess(context, 'Subcourse', subcourse); - - if (user.studentId) { - const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.isCanceled ? 0 : 1), 0); - await Notification.actionTaken(user, 'student_joined_subcourse_meeting', { - relation: `subcourse/${subcourseId}`, - subcourseLecturesCount: lecturesCount.toString(), - }); - } else if (user.pupilId) { - const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.declinedBy.includes(user.userID) ? 0 : 1), 0); - await Notification.actionTaken(user, 'pupil_joined_subcourse_meeting', { - relation: `subcourse/${subcourseId}`, - subcourseLecturesCount: lecturesCount.toString(), - }); - } - - return true; - } -} diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index fcfe3198a..1c09bad67 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -114,7 +114,7 @@ export class MutateCourseResolver { where: { courseId: courseId }, }); const usersSubcourseAchievements = await prisma.user_achievement.findMany({ - where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, userId: user.userID }, + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` } }, include: { template: true }, }); const subcourseAchievements = await Promise.all( @@ -124,14 +124,14 @@ export class MutateCourseResolver { }); }) ); - subcourseAchievements.flat().forEach(async (achievement) => { + for (const achievement of subcourseAchievements.flat()) { const { context } = achievement; context['courseName'] = result.name; await prisma.user_achievement.update({ where: { id: achievement.id }, data: { context }, }); - }); + } return result; } @@ -229,7 +229,6 @@ export class MutateCourseResolver { subcourse.subcourse_instructors_student.forEach(async (instructor) => { await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { courseName: course.name, - subcourseId: subcourse.id.toString(), relation: `subcourse/${subcourse.id}`, }); }); diff --git a/graphql/index.ts b/graphql/index.ts index 60294c5c3..ba2d4b13f 100644 --- a/graphql/index.ts +++ b/graphql/index.ts @@ -71,7 +71,6 @@ import { playground } from './playground'; import { ExtendedFieldsScreenerResolver } from './screener/fields'; import { ExtendedFieldsCooperationResolver } from './cooperation/fields'; import { MutateCooperationResolver } from './cooperation/mutation'; -import { MutateAchievementResolver } from './achievement/mutations'; import { FieldsChatResolver } from './chat/fields'; applyResolversEnhanceMap(authorizationEnhanceMap); @@ -188,7 +187,6 @@ const schema = buildSchemaSync({ MutateCooperationResolver, /* Achievement */ - MutateAchievementResolver, AdminMutationsResolver, ], authChecker, diff --git a/graphql/match/mutations.ts b/graphql/match/mutations.ts index 9c71584ec..7f5129cd1 100644 --- a/graphql/match/mutations.ts +++ b/graphql/match/mutations.ts @@ -102,12 +102,10 @@ export class MutateMatchResolver { if (user.studentId) { await Notification.actionTaken(user, 'student_joined_match_meeting', { - matchId: matchId.toString(), relation: `match/${matchId}`, }); } else if (user.pupilId) { await Notification.actionTaken(user, 'pupil_joined_match_meeting', { - matchId: matchId.toString(), relation: `match/${matchId}`, }); } diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 64a7eef42..39d69fc49 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -102,7 +102,6 @@ export class MutateSubcourseResolver { await Notification.actionTaken(userForStudent(student), 'instructor_course_created', { courseName: course.name, - subcourseId: result.id.toString(), relation: `subcourse/${result.id}`, }); logger.info(`Subcourse(${result.id}) was created for Course(${courseId}) and Student(${student.id})`); @@ -383,4 +382,28 @@ export class MutateSubcourseResolver { logger.info(`Subcourse(${subcourseId}) was manually promoted by instructor(${context.user.userID})`); return true; } + + @Mutation((returns) => Boolean) + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async subcourseMeetingJoin(@Ctx() context: GraphQLContext, @Arg('subcourseId') subcourseId: number) { + const { user } = context; + const subcourse = await prisma.subcourse.findUniqueOrThrow({ where: { id: subcourseId }, include: { course: true, lecture: true } }); + await hasAccess(context, 'Subcourse', subcourse); + + if (user.studentId) { + const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.isCanceled ? 0 : 1), 0); + await Notification.actionTaken(user, 'student_joined_subcourse_meeting', { + relation: `subcourse/${subcourseId}`, + subcourseLecturesCount: lecturesCount.toString(), + }); + } else if (user.pupilId) { + const lecturesCount = subcourse.lecture.reduce((acc, lecture) => acc + (lecture.declinedBy.includes(user.userID) ? 0 : 1), 0); + await Notification.actionTaken(user, 'pupil_joined_subcourse_meeting', { + relation: `subcourse/${subcourseId}`, + subcourseLecturesCount: lecturesCount.toString(), + }); + } + + return true; + } } diff --git a/graphql/user/fields.ts b/graphql/user/fields.ts index f8b8b956e..444c4141c 100644 --- a/graphql/user/fields.ts +++ b/graphql/user/fields.ts @@ -1,16 +1,4 @@ -import { - Student, - Pupil, - Screener, - Secret, - Concrete_notification as ConcreteNotification, - Lecture, - StudentWhereInput, - PupilWhereInput, - Achievement_event, - User_achievement, - Achievement_template, -} from '../generated'; +import { Student, Pupil, Screener, Secret, Concrete_notification as ConcreteNotification, Lecture, StudentWhereInput, PupilWhereInput } from '../generated'; import { Root, Authorized, FieldResolver, Query, Resolver, Arg, Ctx, ObjectType, Field, Int } from 'type-graphql'; import { UNAUTHENTICATED_USER, loginAsUser } from '../authentication'; import { GraphQLContext } from '../context'; From 49dc8e5d0ef7f626a35a8a5e3e034868c500a72b Mon Sep 17 00:00:00 2001 From: LucasFalkowsky <114646768+LucasFalkowsky@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:29:13 +0100 Subject: [PATCH 45/58] feat: achievement integration tests (#956) * add files for achievement integration test * add draft mutations * feat: reward onboarding achievement sequence test * feat: tests for student and pupil conducted match appointment * feat: reward student regular learning achievement test * feat: reward pupil regular learning achievement test * feat: resolver tests * fix: resolve wrong variable name * enable gamification in integration tests * run workflows on all PRs * fix: case changes, purge function for templates, use queries * Update common/achievement/template.ts Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> * fix: fetch user achievements by group --------- Co-authored-by: LomyW Co-authored-by: Daniel Henkel Co-authored-by: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> --- .env.integration-tests | 4 +- .env.integration-tests.debug | 4 +- common/achievement/create.ts | 16 +- common/achievement/template.ts | 4 + common/achievement/util.ts | 11 +- integration-tests/02_screening.ts | 2 +- integration-tests/15_achievements.ts | 1107 ++++++++++++++++++++++++++ integration-tests/index.ts | 1 + 8 files changed, 1133 insertions(+), 16 deletions(-) create mode 100644 integration-tests/15_achievements.ts diff --git a/.env.integration-tests b/.env.integration-tests index 0d92d71bf..d676ba990 100644 --- a/.env.integration-tests +++ b/.env.integration-tests @@ -17,4 +17,6 @@ ZOOM_MEETING_SDK_CLIENT_SECRET="ZOOM_MEETING_SDK_CLIENT_SECRET" ZOOM_MEETING_SDK_CLIENT_ID="ZOOM_MEETING_SDK_CLIENT_ID" ZOOM_API_KEY="ZOOM_API_KEY" ZOOM_API_SECRET="ZOOM_API_SECRET" -ZOOM_ACCOUNT_ID="ZOOM_ACCOUNT_ID" \ No newline at end of file +ZOOM_ACCOUNT_ID="ZOOM_ACCOUNT_ID" + +GAMIFICATION_ACTIVE=true diff --git a/.env.integration-tests.debug b/.env.integration-tests.debug index 881b95069..9e3f11514 100644 --- a/.env.integration-tests.debug +++ b/.env.integration-tests.debug @@ -17,4 +17,6 @@ ZOOM_MEETING_SDK_CLIENT_SECRET="ZOOM_MEETING_SDK_CLIENT_SECRET" ZOOM_MEETING_SDK_CLIENT_ID="ZOOM_MEETING_SDK_CLIENT_ID" ZOOM_API_KEY="ZOOM_API_KEY" ZOOM_API_SECRET="ZOOM_API_SECRET" -ZOOM_ACCOUNT_ID="ZOOM_ACCOUNT_ID" \ No newline at end of file +ZOOM_ACCOUNT_ID="ZOOM_ACCOUNT_ID" + +GAMIFICATION_ACTIVE=true diff --git a/common/achievement/create.ts b/common/achievement/create.ts index a84116cdf..866602d2d 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -55,19 +55,21 @@ async function _createAchievement(currentTemplate: achievem const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); - const contextHasRelation = context && Object.keys(context).includes('relation'); + const contextHasRelation = Object.keys(context).includes('relation'); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { group: currentTemplate.group, }, userId, - AND: contextHasRelation && { - context: { - path: ['relation'], - equals: context['relation'], - }, - }, + ...(contextHasRelation + ? { + context: { + path: ['relation'], + equals: context['relation'], + }, + } + : {}), }, orderBy: { template: { groupOrder: 'asc' } }, }); diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 8ad188168..3db4be802 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -15,6 +15,10 @@ export enum TemplateSelectEnum { // string == metricId, group const achievementTemplates: Map> = new Map(); +export function purgeAchievementTemplateCache() { + achievementTemplates.clear(); +} + async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { if (!achievementTemplates.has(select)) { achievementTemplates.set(select, new Map()); diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 4a23d1c8e..2f0ac331a 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -61,12 +61,11 @@ export async function getBucketContext(userID: string, relation?: string): Promi let subcourses = []; if (!relationType || relationType === 'subcourse') { - let subcourseWhere = whereClause; - if (userType === 'student') { - subcourseWhere = { ...subcourseWhere, subcourse_instructors_student: { some: { studentId: id } } }; - } else { - subcourseWhere = { ...subcourseWhere, subcourse_participants_pupil: { some: { pupilId: id } } }; - } + const userClause = + userType === 'student' + ? { subcourse_instructors_student: { some: { studentId: id } } } + : { subcourse_participants_pupil: { some: { pupilId: id } } }; + const subcourseWhere = { ...whereClause, ...userClause }; subcourses = await prisma.subcourse.findMany({ where: subcourseWhere, select: { diff --git a/integration-tests/02_screening.ts b/integration-tests/02_screening.ts index 81b4c781b..0bb5e0c2d 100644 --- a/integration-tests/02_screening.ts +++ b/integration-tests/02_screening.ts @@ -4,7 +4,7 @@ import { test } from './base'; import { adminClient, createUserClient } from './base/clients'; import { instructorOne, instructorTwo, pupilOne, studentOne } from './01_user'; -const screenerOne = test('Admin can create Screener Account', async () => { +export const screenerOne = test('Admin can create Screener Account', async () => { const email = `test+${randomBytes(10).toString('base64')}@lern-fair.de`; const firstname = randomBytes(10).toString('base64'); const lastname = randomBytes(10).toString('base64'); diff --git a/integration-tests/15_achievements.ts b/integration-tests/15_achievements.ts new file mode 100644 index 000000000..e8a2e31af --- /dev/null +++ b/integration-tests/15_achievements.ts @@ -0,0 +1,1107 @@ +import { createNewPupil, createNewStudent, pupilTwo, studentOne } from './01_user'; +import { test } from './base'; +import { screenerOne } from './02_screening'; +import { adminClient } from './base/clients'; +import { prisma } from '../common/prisma'; +import { achievement_template_for_enum, achievement_type_enum, achievement_action_type_enum, lecture_appointmenttype_enum } from '@prisma/client'; +import { User, getUser } from '../common/user'; +import { Match } from '../graphql/generated'; +import { _createFixedToken } from '../common/secret/token'; +import assert from 'assert'; +import { purgeAchievementTemplateCache } from '../common/achievement/template'; + +async function createTemplates() { + purgeAchievementTemplateCache(); + await createStudentOnboardingTemplates(); + await createPupilOnboardingTemplates(); + await createStudentConductedMatchAppointmentTemplates(); + await createPupilConductedMatchMeetingTemplates(); + await createStudentRegularLearningTemplate(); + await createPupilRegularLearningTemplate(); +} + +void test('Reward student onboarding achievement sequence', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + await createTemplates(); + + // Verify Email + const { student } = await createNewStudent(); + const user = await getUser(student.userID); + + const studentOnboarding1 = await prisma.user_achievement.findFirst({ + where: { + group: 'student_onboarding', + groupOrder: 1, + achievedAt: { not: null }, + userId: user.userID, + }, + }); + assert.ok(studentOnboarding1); + + // Screening + const { client: screenerClient } = await screenerOne; + await screenerClient.request(` + mutation ScreenInstructorOne { + studentTutorScreeningCreate( + studentId: ${student.student.id} + screening: {success: true comment: "" knowsCoronaSchoolFrom: ""} + ) + } + `); + const studentOnboarding2 = await prisma.user_achievement.findFirst({ + where: { + group: 'student_onboarding', + groupOrder: 3, + achievedAt: { not: null }, + userId: user.userID, + }, + }); + assert.ok(studentOnboarding2); + + // Create Certificate of Conduct + const newDate = JSON.stringify(new Date()); + await adminClient.request(` + mutation CreateCertificateOfConduct { + certificateOfConductCreate( + dateOfInspection: ${newDate}, + dateOfIssue: ${newDate}, + criminalRecords: false, + studentId: ${student.student.id}, + ) + } + `); + const studentOnboarding3 = await prisma.user_achievement.findFirst({ + where: { + group: 'student_onboarding', + groupOrder: 4, + achievedAt: { not: null }, + userId: user.userID, + }, + }); + const studentOnboarding4 = await prisma.user_achievement.findFirst({ + where: { + group: 'student_onboarding', + groupOrder: 5, + userId: user.userID, + }, + }); + assert.ok(studentOnboarding3); + assert.ok(studentOnboarding4); +}); + +void test('Reward pupil onboarding achievement sequence', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + + // Verify Email + const { pupil } = await createNewPupil(); + const user = await getUser(pupil.userID); + + const pupilOnboarding1 = await prisma.user_achievement.findFirst({ + where: { + group: 'pupil_onboarding', + groupOrder: 1, + achievedAt: { not: null }, + userId: user.userID, + }, + }); + assert.ok(pupilOnboarding1); + // Screening + await adminClient.request(` + mutation RequestScreening { pupilCreateScreening(pupilId: ${pupil.pupil.id})} + `); + const pupilOnboarding2 = await prisma.user_achievement.findFirst({ + where: { + group: 'pupil_onboarding', + groupOrder: 3, + achievedAt: { not: null }, + userId: user.userID, + }, + }); + const pupilOnboarding3 = await prisma.user_achievement.findFirst({ + where: { + group: 'pupil_onboarding', + groupOrder: 4, + userId: user.userID, + }, + }); + assert.ok(pupilOnboarding2); + assert.ok(pupilOnboarding3); +}); + +void test('Reward student conducted match appointment', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { student, client } = await studentOne; + const user = await getUser(student.userID); + + await client.request(` + mutation { + studentCreateMatchRequest + } + `); + + const { + me: { + student: { matches }, + }, + } = await client.request(` + query StudentWithMatch { + me { + student { + matches { + id + uuid + dissolved + pupil { firstname lastname } + } + } + } + } + `); + const [match] = matches; + + const dates = createDates(); + generateLectures(dates, match, user); + await client.request(` + mutation StudentJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + + const studentJoinedMatchMeetingAchievements = await prisma.user_achievement.findMany({ + where: { + group: 'student_conduct_match_appointment', + userId: user.userID, + }, + }); + assert.ok(studentJoinedMatchMeetingAchievements[0]); + assert.notStrictEqual(studentJoinedMatchMeetingAchievements.length, 0); +}); + +void test('Reward pupil conducted match appointment', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { student } = await studentOne; + const { pupil, client } = await pupilTwo; + const user = await getUser(pupil.userID); + + await client.request(` + mutation { + pupilCreateMatchRequest + } + `); + await adminClient.request(` + mutation CreateManualMatch { + matchAdd(poolName: "lern-fair-now", studentId: ${student.student.id} pupilId: ${pupil.pupil.id}) + } + `); + const { + me: { + pupil: { matches }, + }, + } = await client.request(` + query PupilWithMatch { + me { + pupil { + matches { + id + } + } + } + } + `); + const [match] = matches; + await client.request(` + mutation PupilJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + const pupilJoinedMatchMeetingAchievements = await prisma.user_achievement.findMany({ + where: { + group: 'pupil_conduct_match_appointment', + userId: user.userID, + }, + }); + assert.ok(pupilJoinedMatchMeetingAchievements); + assert.notStrictEqual(pupilJoinedMatchMeetingAchievements.length, 0); +}); + +void test('Reward student regular learning', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + + const { student, client } = await studentOne; + const user = await getUser(student.userID); + const metric = 'student_match_learned_regular'; + + const { + me: { student: s1 }, + } = await client.request(` + query StudentWithMatch { + me { + student { + matches { + id + } + } + } + } + `); + const [match] = s1.matches.filter((el) => !el.dissolved); + // request to generate the achievement with initial record value 1 + await client.request(` + mutation StudentJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + const achievement = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + template: { metrics: { has: metric } }, + context: { path: ['relation'], equals: `match/${match.id}` }, + }, + }); + assert.strictEqual(achievement.recordValue, 1); + + const date = new Date(); + date.setDate(date.getDate() - 7); + await prisma.achievement_event.create({ + data: { + userId: user.userID, + metric: metric, + value: 1, + createdAt: date, + action: 'student_joined_match_meeting', + relation: `match/${match.id}`, + }, + }); + // request to set the achievements record value to 2 due to the past event generated + await client.request(` + mutation StudentJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + + const studentMatchRegularLearningRecord = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + group: 'student_match_regular_learning', + achievedAt: { not: null }, + recordValue: 2, + }, + }); + assert.ok(studentMatchRegularLearningRecord); + + await prisma.achievement_event.deleteMany({ + where: { + userId: user.userID, + metric: metric, + relation: `match/${match.id}`, + }, + }); + await client.request(` + mutation StudentJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + + const studentMatchRegularLearning = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + group: 'student_match_regular_learning', + achievedAt: null, + recordValue: 2, + }, + }); + assert.ok(studentMatchRegularLearning); +}); + +void test('Reward pupil regular learning', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + + const { pupil, client } = await pupilTwo; + const user = await getUser(pupil.userID); + const metric = 'pupil_match_learned_regular'; + + const { + me: { + pupil: { matches }, + }, + } = await client.request(` + query PupilWithMatch { + me { + pupil { + matches { + id + } + } + } + } + `); + const [match] = matches; + // request to generate the achievement with initial record value 1 + await client.request(` + mutation PupilJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + const achievement = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + template: { metrics: { has: metric } }, + context: { path: ['relation'], equals: `match/${match.id}` }, + }, + }); + assert.strictEqual(achievement.recordValue, 1); + + const date = new Date(); + date.setDate(date.getDate() - 7); + await prisma.achievement_event.create({ + data: { + userId: user.userID, + metric: metric, + value: 1, + createdAt: date, + action: 'pupil_joined_match_meeting', + relation: `match/${match.id}`, + }, + }); + // request to set the achievements record value to 2 due to the past event generated + await client.request(` + mutation PupilJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + + await prisma.achievement_event.deleteMany({ + where: { + userId: user.userID, + metric: 'pupil_match_regular_learning', + relation: `match/${match.id}`, + }, + }); + await client.request(` + mutation PupilJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } + `); + + const pupilMatchRegularLearning = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + group: 'pupil_match_regular_learning', + achievedAt: null, + recordValue: 2, + }, + }); + assert.ok(pupilMatchRegularLearning); +}); + +void test('Resolver my achievements', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { client: studentClient } = await studentOne; + const { client: pupilClient } = await pupilTwo; + + const { me: studentMe } = await studentClient.request(` + query achievements { + me { + achievements { + id + } + } + } + `); + const { achievements: studentAchievements } = studentMe; + assert.ok(studentAchievements); + assert.notStrictEqual(studentAchievements.length, 0); + + const { me: pupilMe } = await pupilClient.request(` + query achievements { + me { + achievements { + id + } + } + } + `); + const { achievements: pupilAchievements } = pupilMe; + assert.ok(pupilAchievements); + assert.notStrictEqual(pupilAchievements.length, 0); +}); + +void test('Resolver further (INACTIVE) achievements', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { client: studentClient } = await studentOne; + const { client: pupilClient } = await pupilTwo; + + const { me: studentMe } = await studentClient.request(` + query furtherAchievements { + me { + furtherAchievements { + id + } + } + } + `); + const { furtherAchievements: furtherStudentAchievements } = studentMe; + assert.ok(furtherStudentAchievements); + assert.notStrictEqual(furtherStudentAchievements.length, 0); + + const { me: pupilMe } = await pupilClient.request(` + query furtherAchievements { + me { + furtherAchievements { + id + } + } + } + `); + const { furtherAchievements: furtherPupilAchievements } = pupilMe; + assert.ok(furtherPupilAchievements); + assert.notStrictEqual(furtherPupilAchievements.length, 0); +}); + +void test('Resolver next step achievements', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { client: studentClient } = await studentOne; + const { client: pupilClient } = await pupilTwo; + + const { me: studentMe } = await studentClient.request(` + query nextStepAchievements { + me { + nextStepAchievements { + id + } + } + } + `); + const { nextStepAchievements: nextStepStudentAchievements } = studentMe; + assert.ok(nextStepStudentAchievements); + + const { me: pupilMe } = await pupilClient.request(` + query nextStepAchievements { + me { + nextStepAchievements { + id + } + } + } + `); + const { nextStepAchievements: nextStepPupilAchievements } = pupilMe; + assert.ok(nextStepPupilAchievements); +}); + +void test('Resolver achievement by id', async () => { + await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); + const { client: studentClient, student } = await studentOne; + const { client: pupilClient, pupil } = await pupilTwo; + + const { id: studentAchievementId } = await prisma.user_achievement.findFirst({ + where: { userId: student.userID }, + select: { id: true }, + }); + const { me: studentMe } = await studentClient.request(` + query achievementById { + me { + achievement(id:${studentAchievementId}) { + id + } + } + } + `); + const { achievement: studentAchievement } = studentMe; + assert.ok(studentAchievement); + assert.strictEqual(studentAchievement.id, studentAchievementId); + + const { id: pupilAchievementId } = await prisma.user_achievement.findFirst({ + where: { userId: pupil.userID }, + select: { id: true }, + }); + const { me: pupilMe } = await pupilClient.request(` + query achievementById { + me { + achievement(id:${pupilAchievementId}) { + id + } + } + } + `); + const { achievement: pupilAchievement } = pupilMe; + assert.ok(pupilAchievement); + assert.strictEqual(pupilAchievement.id, pupilAchievementId); +}); + +/* -------------- additional functions for template and data creation ------------- */ +function createDates(): Date[] { + const today = new Date(); + const dates: Date[] = []; + for (let i = 0; i < 5; i++) { + dates[i] = new Date(today); + dates[i].setDate(today.getDate() + (i - 1) * 7); + } + return dates; +} + +function generateLectures(dates: Date[], match: Match, user: User) { + dates.forEach(async (date) => { + await prisma.lecture.create({ + data: { + createdAt: new Date(), + updatedAt: new Date(), + start: date, + duration: 60, + subcourseId: null, + matchId: match.id, + + appointmentType: lecture_appointmenttype_enum.match, + title: null, + description: null, + isCanceled: false, + organizerIds: [user.userID], + participantIds: [], + declinedBy: [], + zoomMeetingId: null, + zoomMeetingReport: [], + instructorId: null, + override_meeting_link: null, + }, + select: { + id: true, + }, + }); + }); +} + +const createStudentOnboardingTemplates = async () => { + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_verified'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 1, + stepName: 'Verifizieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_00', + achievedImage: '', + actionName: 'E-Mail erneut senden', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Action, + condition: 'student_verified_events > 0', + conditionDataAggregations: { student_verified_events: { metric: 'student_onboarding_verified', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_appointment_booked'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 2, + stepName: 'Kennenlerngespräch buchen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_01', + achievedImage: '', + actionName: 'Termin vereinbaren', + actionRedirectLink: 'https://calendly.com', + actionType: achievement_action_type_enum.Action, + condition: 'student_appointment_booked_events > 0', + conditionDataAggregations: { + student_appointment_booked_events: { metric: 'student_onboarding_appointment_booked', aggregator: 'count' }, + }, + isActive: false, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 3, + stepName: 'Screening absolvieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Screening absolvieren', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Appointment, + condition: 'student_screened_events > 0', + conditionDataAggregations: { student_screened_events: { metric: 'student_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_coc_success'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 4, + stepName: 'Führungszeugnis einreichen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Zeugnis einreichen', + actionRedirectLink: 'mailto:fz@lern-fair.de', + actionType: achievement_action_type_enum.Action, + condition: 'student_coc_success_events > 0', + conditionDataAggregations: { student_coc_success_events: { metric: 'student_onboarding_coc_success', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['student_onboarding_coc_success'], + templateFor: achievement_template_for_enum.Global, + group: 'student_onboarding', + groupOrder: 5, + stepName: 'Onboarding abgeschlossen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Flugticket', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'student_coc_success_events > 0', + conditionDataAggregations: { student_coc_success_events: { metric: 'student_onboarding_coc_success', aggregator: 'count' } }, + isActive: true, + }, + }); +}; +const createPupilOnboardingTemplates = async () => { + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_verified'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 1, + stepName: 'Verifizieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_00', + achievedImage: '', + actionName: 'E-Mail erneut senden', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Action, + condition: 'pupil_verified_events > 0', + conditionDataAggregations: { pupil_verified_events: { metric: 'pupil_onboarding_verified', aggregator: 'count' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_appointment_booked'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 2, + stepName: 'Kennenlerngespräch buchen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_01', + achievedImage: '', + actionName: 'Termin vereinbaren', + actionRedirectLink: 'https://calendly.com', + actionType: achievement_action_type_enum.Action, + condition: 'pupil_appointment_booked_events > 0', + conditionDataAggregations: { + pupil_appointment_booked_events: { metric: 'pupil_onboarding_appointment_booked', aggregator: 'count' }, + }, + isActive: false, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 3, + stepName: 'Screening absolvieren', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Puzzle_02', + achievedImage: '', + actionName: 'Screening absolvieren', + actionRedirectLink: '', + actionType: achievement_action_type_enum.Appointment, + condition: 'pupil_screened_events > 0', + conditionDataAggregations: { pupil_screened_events: { metric: 'pupil_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); + + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + metrics: ['pupil_onboarding_screened'], + templateFor: achievement_template_for_enum.Global, + group: 'pupil_onboarding', + groupOrder: 4, + stepName: 'Onboarding abgeschlossen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Jetzt durchstarten', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Flugticket', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'pupil_screened_events > 0', + conditionDataAggregations: { pupil_screened_events: { metric: 'pupil_onboarding_screened', aggregator: 'count' } }, + isActive: true, + }, + }); +}; +const createStudentConductedMatchAppointmentTemplates = async () => { + await prisma.achievement_template.create({ + data: { + name: '1. durchgeführter Termin', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_01', + achievedImage: '', + actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', + actionRedirectLink: null, + actionType: achievement_action_type_enum.Action, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 0', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 2, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_02', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 2', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 3, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_03', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 4', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 4, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_04', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 9', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 5, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_05', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 14', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + metrics: ['student_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'student_conduct_match_appointment', + groupOrder: 6, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_06', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'student_match_appointments_count > 24', + conditionDataAggregations: { + student_match_appointments_count: { metric: 'student_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + }, + isActive: true, + }, + }); +}; +const createPupilConductedMatchMeetingTemplates = async () => { + await prisma.achievement_template.create({ + data: { + name: '1. durchgeführter Termin', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_01', + achievedImage: '', + actionName: 'Absolviere deinen ersten Termin, um diesen Erfolg zu erhalten', + actionRedirectLink: null, + actionType: achievement_action_type_enum.Action, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 0', + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 1 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 2, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_02', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 2', + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 3 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 3, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_03', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 4', + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 5 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 4, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_04', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 9', + conditionDataAggregations: { + student_conducted_match_appointments: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 10 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 5, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_05', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 14', + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 15 }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + metrics: ['pupil_conducted_match_appointment'], + templateFor: achievement_template_for_enum.Global_Matches, + group: 'pupil_conduct_match_appointment', + groupOrder: 6, + stepName: '', + type: achievement_type_enum.TIERED, + subtitle: '1:1 Lernunterstützungen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Polaroid_06', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Dieser Text muss noch geliefert werden', + condition: 'pupil_match_appointments_count > 24', + conditionDataAggregations: { + pupil_match_appointments_count: { metric: 'pupil_conducted_match_appointment', aggregator: 'count', valueToAchieve: 25 }, + }, + isActive: true, + }, + }); +}; +const createStudentRegularLearningTemplate = async () => { + await prisma.achievement_template.create({ + data: { + name: 'Regelmäßiges Lernen', + metrics: ['student_match_learned_regular'], + templateFor: achievement_template_for_enum.Match, + group: 'student_match_regular_learning', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.STREAK, + subtitle: 'Nachhilfe mit {{matchpartner}}', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Hat_grey', + achievedImage: 'Hat_gold', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Rekord gebrochen.', + condition: 'student_match_learning_events > recordValue', + conditionDataAggregations: { + student_match_learning_events: { + metric: 'student_match_learned_regular', + aggregator: 'lastStreakLength', + createBuckets: 'by_weeks', + bucketAggregator: 'presenceOfEvents', + }, + }, + isActive: true, + }, + }); +}; +const createPupilRegularLearningTemplate = async () => { + await prisma.achievement_template.create({ + data: { + name: 'Regelmäßiges Lernen', + metrics: ['pupil_match_learned_regular'], + templateFor: achievement_template_for_enum.Match, + group: 'pupil_match_regular_learning', + groupOrder: 1, + stepName: '', + type: achievement_type_enum.STREAK, + subtitle: 'Nachhilfe mit {{matchpartner}}', + description: 'Dieser Text muss noch geliefert werden.', + image: 'Hat_grey', + achievedImage: 'Hat_gold', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: 'Juhu! Rekord gebrochen.', + condition: 'pupil_match_learning_events > recordValue', + conditionDataAggregations: { + pupil_match_learning_events: { + metric: 'pupil_match_learned_regular', + aggregator: 'lastStreakLength', + createBuckets: 'by_weeks', + bucketAggregator: 'presenceOfEvents', + }, + }, + isActive: true, + }, + }); +}; diff --git a/integration-tests/index.ts b/integration-tests/index.ts index ff65644ad..e7ce97820 100644 --- a/integration-tests/index.ts +++ b/integration-tests/index.ts @@ -34,5 +34,6 @@ import './12_notifications'; /* Account Deactivation - Independent, but needs to be last */ import './13_deactivation'; import './14_redaction'; +import './15_achievements'; void finalizeTests(); From 98ed6026e4613173390b5d666b2d14f8562ba5db Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 27 Jan 2024 12:34:33 +0100 Subject: [PATCH 46/58] fix: unit tests --- common/achievement/bucket.spec.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts index 0964eb238..717f3cf3c 100644 --- a/common/achievement/bucket.spec.ts +++ b/common/achievement/bucket.spec.ts @@ -23,7 +23,7 @@ describe('test create buckets by_lecture_start', () => { { kind: 'time', startTime: moment('2023-08-14T23:50:00.000Z').toDate(), - endTime: moment('2023-08-15T01:10:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05:00.000Z').toDate(), relation: 'match', }, ], @@ -35,13 +35,13 @@ describe('test create buckets by_lecture_start', () => { { kind: 'time', startTime: moment('2023-08-13T23:50:00.000Z').toDate(), - endTime: moment('2023-08-14T01:10:00.000Z').toDate(), + endTime: moment('2023-08-14T01:05:00.000Z').toDate(), relation: 'match', }, { kind: 'time', startTime: moment('2023-08-14T23:50:00.000Z').toDate(), - endTime: moment('2023-08-15T01:10:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05:00.000Z').toDate(), relation: 'match', }, ], @@ -62,7 +62,7 @@ describe('test create buckets by_lecture_start', () => { { kind: 'time', startTime: moment('2023-08-14T23:50:00.000Z').toDate(), - endTime: moment('2023-08-15T01:10:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05:00.000Z').toDate(), relation: 'subcourse', }, ], @@ -74,13 +74,13 @@ describe('test create buckets by_lecture_start', () => { { kind: 'time', startTime: moment('2023-08-13T23:50:00.000Z').toDate(), - endTime: moment('2023-08-14T01:10:00.000Z').toDate(), + endTime: moment('2023-08-14T01:05:00.000Z').toDate(), relation: 'subcourse', }, { kind: 'time', startTime: moment('2023-08-14T23:50:00.000Z').toDate(), - endTime: moment('2023-08-15T01:10:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05:00.000Z').toDate(), relation: 'subcourse', }, ], @@ -100,26 +100,26 @@ describe('test create buckets by_lecture_start', () => { expectedBuckets: [ { kind: 'time', - startTime: moment('2023-08-14T23:50:00.000Z').toDate(), - endTime: moment('2023-08-15T00:40:00.000Z').toDate(), + startTime: moment('2023-07-31T23:50:00.000Z').toDate(), + endTime: moment('2023-08-01T00:50:00.000Z').toDate(), relation: 'match', }, { kind: 'time', - startTime: moment('2023-07-31T23:50:00.000Z').toDate(), - endTime: moment('2023-08-01T00:55:00.000Z').toDate(), + startTime: moment('2023-08-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T00:35:00.000Z').toDate(), relation: 'match', }, { kind: 'time', - startTime: moment('2023-08-13T23:50:00.000Z').toDate(), - endTime: moment('2023-08-14T01:55:00.000Z').toDate(), + startTime: moment('2023-08-07T23:50:00.000Z').toDate(), + endTime: moment('2023-08-08T04:55:00.000Z').toDate(), relation: 'subcourse', }, { kind: 'time', - startTime: moment('2023-08-07T23:50:00.000Z').toDate(), - endTime: moment('2023-08-08T05:00:00.000Z').toDate(), + startTime: moment('2023-08-13T23:50:00.000Z').toDate(), + endTime: moment('2023-08-14T01:50:00.000Z').toDate(), relation: 'subcourse', }, ], From a1d23f3079095b2b566bce8d3c34b95ee7a53056 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 27 Jan 2024 12:50:34 +0100 Subject: [PATCH 47/58] fix: achievement identification --- common/achievement/create.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 866602d2d..a591a606d 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -17,17 +17,12 @@ export async function findUserAchievement( userId: string, context: SpecificNotificationContext ): Promise { - const contextHasRelation = context && Object.keys(context).includes('relation'); + const contextFilter = createRelationContextFilter(context); const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, userId, - AND: contextHasRelation && { - context: { - path: ['relation'], - equals: context['relation'], - }, - }, + context: contextFilter, }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); @@ -55,21 +50,14 @@ async function _createAchievement(currentTemplate: achievem const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); - const contextHasRelation = Object.keys(context).includes('relation'); + const contextFilter = createRelationContextFilter(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { group: currentTemplate.group, }, userId, - ...(contextHasRelation - ? { - context: { - path: ['relation'], - equals: context['relation'], - }, - } - : {}), + context: contextFilter, }, orderBy: { template: { groupOrder: 'asc' } }, }); From 6c48d0d5704e40ba2a05729deae9d336dcfce92e Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sat, 27 Jan 2024 13:22:04 +0100 Subject: [PATCH 48/58] fix: typesystem for achievements (#966) --- common/achievement/bucket.spec.ts | 84 ++++++++++++++++++++++++----- common/achievement/bucket.ts | 14 +++-- common/achievement/create.ts | 23 +++++--- common/achievement/evaluate.spec.ts | 10 ++-- common/achievement/evaluate.ts | 11 ++-- common/achievement/get.ts | 68 +++++++++++++---------- common/achievement/index.ts | 47 ++++++++-------- common/achievement/metric.ts | 16 ++---- common/achievement/metrics.ts | 2 +- common/achievement/template.ts | 5 +- common/achievement/types.ts | 14 ++--- common/achievement/util.ts | 31 ++++++----- common/logger/logger.ts | 2 +- graphql/types/achievement.ts | 14 ++--- 14 files changed, 216 insertions(+), 125 deletions(-) diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts index 717f3cf3c..e378c45dd 100644 --- a/common/achievement/bucket.spec.ts +++ b/common/achievement/bucket.spec.ts @@ -179,24 +179,54 @@ describe('test create buckets by_week', () => { name: 'should create one bucket if recordValue is 0', recordValue: 0, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-14T00:00:00.000Z').toDate(), + endTime: moment('2023-08-20T23:59:59.999').toDate(), + relation: undefined, + }, ], }, { name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', recordValue: 1, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-08-07T00:00:00.000Z').toDate(), endTime: moment('2023-08-13T23:59:59.999Z').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-14T00:00:00.000Z').toDate(), + endTime: moment('2023-08-20T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-08-07T00:00:00.000Z').toDate(), + endTime: moment('2023-08-13T23:59:59.999Z').toDate(), + relation: undefined, + }, ], }, { name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', recordValue: 2, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-14T00:00:00.000Z').toDate(), endTime: moment('2023-08-20T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-08-07T00:00:00.000Z').toDate(), endTime: moment('2023-08-13T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-07-31T00:00:00.000Z').toDate(), endTime: moment('2023-08-06T23:59:59.999Z').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-14T00:00:00.000Z').toDate(), + endTime: moment('2023-08-20T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-08-07T00:00:00.000Z').toDate(), + endTime: moment('2023-08-13T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-07-31T00:00:00.000Z').toDate(), + endTime: moment('2023-08-06T23:59:59.999Z').toDate(), + relation: undefined, + }, ], }, ]; @@ -235,24 +265,54 @@ describe('test create buckets by_months', () => { name: 'should create one bucket if recordValue is 0', recordValue: 0, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-01T00:00:00.000Z').toDate(), + endTime: moment('2023-08-31T23:59:59.999Z').toDate(), + relation: undefined, + }, ], }, { name: 'should create two buckets if recordValue is 1. Elements should be ordered desc', recordValue: 1, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-07-01T00:00:00.000').toDate(), endTime: moment('2023-07-31T23:59:59.999Z').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-01T00:00:00.000Z').toDate(), + endTime: moment('2023-08-31T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-07-01T00:00:00.000').toDate(), + endTime: moment('2023-07-31T23:59:59.999Z').toDate(), + relation: undefined, + }, ], }, { name: 'should create three buckets if recordValue is 2. Elements should be ordered desc', recordValue: 2, expectedBuckets: [ - { kind: 'time', startTime: moment('2023-08-01T00:00:00.000Z').toDate(), endTime: moment('2023-08-31T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-07-01T00:00:00.000Z').toDate(), endTime: moment('2023-07-31T23:59:59.999Z').toDate(), relation: null }, - { kind: 'time', startTime: moment('2023-06-01T00:00:00.000Z').toDate(), endTime: moment('2023-06-30T23:59:59.999Z').toDate(), relation: null }, + { + kind: 'time', + startTime: moment('2023-08-01T00:00:00.000Z').toDate(), + endTime: moment('2023-08-31T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-07-01T00:00:00.000Z').toDate(), + endTime: moment('2023-07-31T23:59:59.999Z').toDate(), + relation: undefined, + }, + { + kind: 'time', + startTime: moment('2023-06-01T00:00:00.000Z').toDate(), + endTime: moment('2023-06-30T23:59:59.999Z').toDate(), + relation: undefined, + }, ], }, ]; diff --git a/common/achievement/bucket.ts b/common/achievement/bucket.ts index dcea1607f..742b35026 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -46,7 +46,6 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, by_weeks: { function: (bucketContext): GenericBucketConfig => { - // TODO: what if the recordValue is not a number or negative? const { recordValue: weeks } = bucketContext; // the buckets are created in a desc order const today = moment(); @@ -55,6 +54,10 @@ export const bucketCreatorDefs: BucketCreatorDefs = { buckets: [], }; + if (weeks === undefined || weeks === null) { + return timeBucket; + } + /* This is to look at the last few weeks before the current event so that we can evaluate whether the streak has been interrupted for the last few weeks or whether we have a new record. --- @@ -67,7 +70,7 @@ export const bucketCreatorDefs: BucketCreatorDefs = { const weeksBefore = today.clone().subtract(i, 'week'); timeBucket.buckets.push({ kind: 'time', - relation: null, + relation: undefined, startTime: weeksBefore.startOf('week').toDate(), endTime: weeksBefore.endOf('week').toDate(), }); @@ -78,7 +81,6 @@ export const bucketCreatorDefs: BucketCreatorDefs = { }, by_months: { function: (bucketContext): GenericBucketConfig => { - // TODO: what if the recordValue is not a number or negative? const { recordValue: months } = bucketContext; // the buckets are created in a desc order @@ -88,11 +90,15 @@ export const bucketCreatorDefs: BucketCreatorDefs = { buckets: [], }; + if (months === undefined || months === null) { + return timeBucket; + } + for (let i = 0; i < months + 1; i++) { const monthsBefore = today.clone().subtract(i, 'month'); timeBucket.buckets.push({ kind: 'time', - relation: null, + relation: undefined, startTime: monthsBefore.startOf('month').toDate(), endTime: monthsBefore.endOf('month').toDate(), }); diff --git a/common/achievement/create.ts b/common/achievement/create.ts index a591a606d..ada43cf42 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -5,8 +5,8 @@ import { TemplateSelectEnum, getAchievementTemplates } from './template'; import tracer from '../logger/tracing'; import { AchievementToCheck } from './types'; -function createRelationContextFilter(context: SpecificNotificationContext): Prisma.JsonFilter { - if (context.relation) { +function createRelationContextFilter(context?: SpecificNotificationContext): Prisma.JsonFilter { + if (context && context.relation) { return { path: ['relation'], equals: context.relation }; } return { path: ['relation'], equals: Prisma.AnyNull }; @@ -15,8 +15,8 @@ function createRelationContextFilter(context: SpecificNotif export async function findUserAchievement( templateId: number, userId: string, - context: SpecificNotificationContext -): Promise { + context?: SpecificNotificationContext +): Promise { const contextFilter = createRelationContextFilter(context); const userAchievement = await prisma.user_achievement.findFirst({ where: { @@ -33,12 +33,12 @@ async function getOrCreateUserAchievement( template: achievement_template, userId: string, context: SpecificNotificationContext -): Promise { +): 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: AchievementToCheck = await findUserAchievement(template.id, userId, !isGlobal ? context : undefined); + const existingUserAchievement = await findUserAchievement(template.id, userId, !isGlobal ? context : undefined); if (!existingUserAchievement) { return await createAchievement(template, userId, context); } @@ -48,7 +48,11 @@ async function getOrCreateUserAchievement( const createAchievement = tracer.wrap('achievement.createAchievement', _createAchievement); async function _createAchievement(currentTemplate: achievement_template, userId: string, context: SpecificNotificationContext) { const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); - const templatesForGroup = templatesByGroup.get(currentTemplate.group).sort((a, b) => a.groupOrder - b.groupOrder); + if (!templatesByGroup.has(currentTemplate.group)) { + return null; + } + + const templatesForGroup = templatesByGroup.get(currentTemplate.group)!.sort((a, b) => a.groupOrder - b.groupOrder); const contextFilter = createRelationContextFilter(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ @@ -68,6 +72,8 @@ async function _createAchievement(currentTemplate: achievem const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); return createdUserAchievement; } + + return null; } async function createNextUserAchievement( @@ -77,7 +83,7 @@ async function createNextUserAchievement( context: SpecificNotificationContext ) { if (templatesForGroup.length <= nextStepIndex) { - return; + return null; } const nextStepTemplate = templatesForGroup[nextStepIndex]; const achievedAt = @@ -99,6 +105,7 @@ async function createNextUserAchievement( }); return createdUserAchievement; } + return null; } export { getOrCreateUserAchievement, createAchievement }; diff --git a/common/achievement/evaluate.spec.ts b/common/achievement/evaluate.spec.ts index 7c6ae6877..165dce666 100644 --- a/common/achievement/evaluate.spec.ts +++ b/common/achievement/evaluate.spec.ts @@ -11,7 +11,7 @@ function createTestEvent({ metric, value, relation, ts }: { metric: string; valu id: 1, action: 'test', metric: metric, - relation: relation, + relation: relation ?? '', value: value, userId: 'student/1', createdAt: eventTs, @@ -89,7 +89,7 @@ describe('evaluate condition without default bucket aggregator', () => { const res = await evaluateAchievement(userId, condition, dataAggr, 0, undefined); expect(res).toBeDefined(); - expect(res.conditionIsMet).toBe(expectedResult); + expect(res?.conditionIsMet).toBe(expectedResult); }); }); @@ -213,7 +213,7 @@ describe('evaluate record value condition with time buckets', () => { const res = await evaluateAchievement(testUserId, condition, dataAggr, recordValue, undefined); expect(res).toBeDefined(); - expect(res.conditionIsMet).toBe(expectNewRecord); + expect(res?.conditionIsMet).toBe(expectNewRecord); }); }); @@ -292,7 +292,7 @@ function createLecture({ start }: { start: Date }): lecture { participantIds: [], declinedBy: [], zoomMeetingId: null, - zoomMeetingReport: null, + zoomMeetingReport: [], instructorId: null, override_meeting_link: null, }; @@ -400,6 +400,6 @@ describe('evaluate bucket with match / subcourse context', () => { const res = await evaluateAchievement(testUserId, condition, dataAggr, 0, undefined); expect(res).toBeDefined(); - expect(res.conditionIsMet).toBe(expectNewRecord); + expect(res?.conditionIsMet).toBe(expectNewRecord); }); }); diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index f7f45c3bd..cf9c85386 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -1,5 +1,5 @@ import { Achievement_event } from '../../graphql/generated'; -import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types'; +import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult, GenericBucketConfig, TimeBucket } from './types'; import { prisma } from '../prisma'; import { aggregators } from './aggregator'; import swan from '@onlabsorg/swan-js'; @@ -16,7 +16,7 @@ async function _evaluateAchievement( userId: string, condition: string, dataAggregation: ConditionDataAggregations, - recordValue: number, + recordValue?: number, relation?: string ): Promise { // We only care about metrics that are used for the data aggregation @@ -40,7 +40,9 @@ async function _evaluateAchievement( } const resultObject: Record = {}; - resultObject['recordValue'] = recordValue; + if (recordValue !== undefined) { + resultObject['recordValue'] = recordValue; + } for (const key in dataAggregation) { if (!dataAggregation[key]) { @@ -111,7 +113,7 @@ const createDefaultBuckets = (events: Achievement_event[]): BucketEvents[] => { })); }; -const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => { +const createTimeBuckets = (events: Achievement_event[], bucketConfig: GenericBucketConfig): BucketEvents[] => { const { buckets } = bucketConfig; const bucketsWithEvents: BucketEvents[] = buckets.map((bucket) => { // values will be sorted in a desc order @@ -124,6 +126,7 @@ const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConf kind: bucket.kind, startTime: bucket.startTime, endTime: bucket.endTime, + relation: bucket.relation, events: filteredEvents, }; }); diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 150b5444a..b0d4a042d 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -1,11 +1,12 @@ import { prisma } from '../prisma'; import { achievement_type_enum, Prisma } from '@prisma/client'; -import { Achievement, achievement_state } from '../../graphql/types/achievement'; +import { Achievement, achievement_state, Step } from '../../graphql/types/achievement'; import { User } from '../user'; import { ConditionDataAggregations } from './types'; import { getAchievementState, renderAchievementWithContext, transformPrismaJson } from './util'; import { evaluateAchievement } from './evaluate'; import { getAchievementImageURL } from './util'; +import { isDefined } from './util'; export async function getUserAchievementsWithTemplates(user: User) { const userAchievementsWithTemplates = await prisma.user_achievement.findMany({ @@ -18,7 +19,7 @@ type ThenArg = T extends PromiseLike ? U : T; export type achievements_with_template = ThenArg>; const getAchievementById = async (user: User, achievementId: number): Promise => { - const userAchievement = await prisma.user_achievement.findUnique({ + const userAchievement = await prisma.user_achievement.findUniqueOrThrow({ where: { id: achievementId }, include: { template: true }, }); @@ -34,7 +35,7 @@ const getNextStepAchievements = async (user: User): Promise => { }); const userAchievementGroups: { [groupRelation: string]: achievements_with_template } = {}; userAchievements.forEach((ua) => { - const relation = ua.context['relation'] || null; + const relation = (ua.context as Prisma.JsonObject)['relation'] || null; const key = relation ? `${ua.template.group}/${relation}` : ua.template.group; if (!userAchievementGroups[key]) { userAchievementGroups[key] = []; @@ -69,10 +70,11 @@ const getFurtherAchievements = async (user: User): Promise => { }); const tieredAchievements = tieredTemplates.map((template) => { - const dataAggregationKeys = Object.keys(template.conditionDataAggregations); - const maxValue = dataAggregationKeys + const dataAggr = template.conditionDataAggregations as Prisma.JsonObject; + const maxValue = Object.keys(dataAggr) .map((key) => { - return Number(template.conditionDataAggregations[key].valueToAchieve); + const val = dataAggr[key] as number; + return Number(val); }) .reduce((a, b) => a + b, 0); const achievement: Achievement = { @@ -82,7 +84,7 @@ const getFurtherAchievements = async (user: User): Promise => { description: template.description, image: getAchievementImageURL(template.image), alternativeText: 'alternativeText', - actionType: template.actionType, + actionType: template.actionType ?? undefined, achievementType: template.type, achievementState: achievement_state.INACTIVE, steps: null, @@ -141,11 +143,12 @@ const generateReorderedAchievementData = async (groups: { [group: string]: achie return achievements.flat(); }; +// TODO: refactor const assembleAchievementData = async (userAchievements: achievements_with_template, user: User): Promise => { let currentAchievementIndex = userAchievements.findIndex((ua) => !ua.achievedAt); currentAchievementIndex = currentAchievementIndex >= 0 ? currentAchievementIndex : userAchievements.length - 1; - const achievementContext = transformPrismaJson(user, userAchievements[currentAchievementIndex].context); + const achievementContext = transformPrismaJson(user, userAchievements[currentAchievementIndex].context as Prisma.JsonObject); const currentAchievementTemplate = renderAchievementWithContext(userAchievements[currentAchievementIndex], achievementContext); const achievementTemplates = await prisma.achievement_template.findMany({ @@ -157,33 +160,40 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ const isNewAchievement = state === achievement_state.COMPLETED && !userAchievements[currentAchievementIndex].isSeen; - const condition = currentAchievementTemplate.condition.includes('recordValue') - ? currentAchievementTemplate.condition.replace('recordValue', (userAchievements[currentAchievementIndex].recordValue + 1).toString()) - : currentAchievementTemplate.condition; + // TODO: check if this is needed? + const condition = + currentAchievementTemplate.condition.includes('recordValue') && userAchievements[currentAchievementIndex].recordValue !== null + ? currentAchievementTemplate.condition.replace('recordValue', (userAchievements[currentAchievementIndex].recordValue! + 1).toString()) + : currentAchievementTemplate.condition; - let maxValue: number; - let currentValue: number; + let maxValue: number = 0; + let currentValue: number = 0; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { - const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations); - const relation = userAchievements[currentAchievementIndex].context['relation'] || null; + const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations as Prisma.JsonObject); + const currentAchievementContext = userAchievements[currentAchievementIndex].context as Prisma.JsonObject; + const relation = currentAchievementContext['relation'] as string; const evaluationResult = await evaluateAchievement( user.userID, condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, - userAchievements[currentAchievementIndex].recordValue, + userAchievements[currentAchievementIndex].recordValue || undefined, relation ); - currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); - maxValue = - currentAchievementTemplate.type === achievement_type_enum.STREAK - ? userAchievements[currentAchievementIndex].recordValue > currentValue - ? userAchievements[currentAchievementIndex].recordValue - : currentValue - : dataAggregationKeys - .map((key) => { - return Number(currentAchievementTemplate.conditionDataAggregations[key].valueToAchieve); - }) - .reduce((a, b) => a + b, 0); + // TODO: check if this will still include the recodValue + if (evaluationResult) { + currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); + maxValue = + currentAchievementTemplate.type === achievement_type_enum.STREAK + ? userAchievements[currentAchievementIndex].recordValue !== null && userAchievements[currentAchievementIndex].recordValue! > currentValue + ? userAchievements[currentAchievementIndex].recordValue! + : currentValue + : dataAggregationKeys + .map((key) => { + // TODO: check if we can remove valueToAchieve + return Number((currentAchievementTemplate.conditionDataAggregations as any)[key].valueToAchieve as string); + }) + .reduce((a, b) => a + b, 0); + } } else { currentValue = currentAchievementIndex; maxValue = achievementTemplates.length - 1; @@ -203,7 +213,7 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ achievementState: state, steps: currentAchievementTemplate.stepName ? achievementTemplates - .map((achievement, index) => { + .map((achievement, index): Step | null => { // if a achievementTemplate has a stepName, it means that it must have multiple steps resulting in it having a sequence of achievements / templates // for every achievement in the sortedGroupAchievements, we create a step object with the stepName (descirption) and isActive property for the achievement step currently active but unachieved if (index < achievementTemplates.length - 1 && achievement.isActive) { @@ -214,7 +224,7 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ } return null; }) - .filter((step) => step) + .filter(isDefined) : null, maxSteps: maxValue, currentStep: currentValue, diff --git a/common/achievement/index.ts b/common/achievement/index.ts index 73202ab60..01b300272 100644 --- a/common/achievement/index.ts +++ b/common/achievement/index.ts @@ -52,27 +52,27 @@ async function _rewardActionTaken(user: User, actionId: ID, for (const [groupName, group] of templatesByGroups) { try { await tracer.trace('achievement.evaluateAchievementGroups', async (span) => { - span.setTag('achievement.group', groupName); + span?.setTag('achievement.group', groupName); logger.info('evaluate achievement group', { groupName }); - let achievementToCheck: AchievementToCheck; + let achievementToCheck: AchievementToCheck | undefined = undefined; 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) { + if (userAchievement && (userAchievement.achievedAt === null || userAchievement.template?.type === achievement_type_enum.STREAK)) { logger.info('found achievement to check', { achievementId: userAchievement.id, - achievementName: userAchievement.template.name, - type: userAchievement.template.type, + achievementName: userAchievement.template?.name, + type: userAchievement.template?.type, }); achievementToCheck = userAchievement; break; } } - span.setTag('achievement.foundToCheck', !!achievementToCheck); + span?.setTag('achievement.foundToCheck', !!achievementToCheck); if (achievementToCheck) { - span.setTag('achievement.id', achievementToCheck.id); + span?.setTag('achievement.id', achievementToCheck.id); await tracer.trace('achievement.checkUserAchievement', () => checkUserAchievement(achievementToCheck as UserAchievementTemplate, actionEvent) ); @@ -80,7 +80,7 @@ async function _rewardActionTaken(user: User, actionId: ID, logger.info('group evaluation done', { groupName }); }); } catch (e) { - logger.error(`Error occurred while checking achievement for user`, e, { userId: user.userID }); + logger.error(`Error occurred while checking achievement for user`, e as Error, { userId: user.userID }); } } } @@ -119,10 +119,15 @@ async function checkUserAchievement(userAchievement: UserAc actionId: event.actionId, achievementId: userAchievement.id, condition: userAchievement.template.condition, - conditionIsMet: evaluationResult.conditionIsMet, - resultObject: JSON.stringify(evaluationResult.resultObject, null, 4), + conditionIsMet: evaluationResult?.conditionIsMet, + resultObject: JSON.stringify(evaluationResult?.resultObject, null, 4), }); + if (evaluationResult === null) { + // TODO: handle this case + return; + } + if (evaluationResult.conditionIsMet) { const conditionDataAggregations = userAchievement?.template.conditionDataAggregations as ConditionDataAggregations; const dataAggregationKey = Object.keys(conditionDataAggregations)[0]; @@ -145,21 +150,18 @@ async function isAchievementConditionMet(achievement: UserA template: { condition, conditionDataAggregations }, } = achievement; if (!condition) { - logger.error(`No condition found for achievement`, null, { template: achievement.template.name, achievementId: achievement.id }); - return { conditionIsMet: false, resultObject: null }; + logger.error(`No condition found for achievement`, undefined, { template: achievement.template.name, achievementId: achievement.id }); + return { conditionIsMet: false, resultObject: {} }; } - const { conditionIsMet, resultObject } = await evaluateAchievement( - userId, - condition, - conditionDataAggregations as ConditionDataAggregations, - recordValue, - event.context.relation - ); - return { conditionIsMet, resultObject }; + const result = await evaluateAchievement(userId, condition, conditionDataAggregations as ConditionDataAggregations, recordValue, event.context.relation); + if (result === undefined) { + return null; + } + return result; } -async function rewardUser(evaluationResult: number, userAchievement: UserAchievementTemplate, event: ActionEvent) { +async function rewardUser(evaluationResult: number | null, userAchievement: UserAchievementTemplate, event: ActionEvent) { let newRecordValue = null; if (typeof userAchievement.recordValue === 'number' && evaluationResult) { newRecordValue = evaluationResult; @@ -182,6 +184,9 @@ async function rewardUser(evaluationResult: number, userAch * The final template in the sequence serves solely to display information about the sequential achievement being rewarded. * Before generating the reward achievement for a sequential achievement, the system checks if the last achievement to be evaluated has been achieved. */ + if (!groupTemplates) { + return updatedAchievement; + } const lastTemplate = groupTemplates[groupTemplates.length - 2]; if (groupOrder === lastTemplate.groupOrder) { await actionTakenAt(new Date(event.at), event.user, 'user_achievement_reward_issued', { diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index c2be5c44d..58c8365f9 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -1,19 +1,9 @@ -import { ActionID, SpecificNotificationContext } from '../notification/actions'; +import { ActionID } from '../notification/actions'; import { registerAllMetrics } from './metrics'; -import { Metric } from './types'; - -// Maps A | B to A & B (using contra-variant position - c.f. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types) -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - -// Derives the context for a given list of ActionIDs -type ContextForActions = UnionToIntersection< - // By using UnionToIntersection, it combines specific contexts for the provided ActionIDs - // Creating a union of all specific contexts for the given ActionIDs - { [Index in keyof ActionIDs]: SpecificNotificationContext }[number] ->; +import { FormulaFunction, Metric } from './types'; // This function utilizes generics to ensure flexibility in the ActionIDs and their respective contexts, allowing for dynamic metric creation. -function createMetric>(metricName: string, onActions: T, formula: (context: K) => number): Metric { +function createMetric(metricName: string, onActions: T[], formula: FormulaFunction): Metric { return { metricName, onActions, diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts index 137844dff..a1e59a3f0 100644 --- a/common/achievement/metrics.ts +++ b/common/achievement/metrics.ts @@ -21,7 +21,7 @@ function registerMetric(metric: Metric) { if (!metricsByAction.has(actionID)) { metricsByAction.set(actionID, []); } - metricsByAction.get(actionID).push(metric); + metricsByAction.get(actionID)!.push(metric); }); metricByName.set(metricName, metric); diff --git a/common/achievement/template.ts b/common/achievement/template.ts index 3db4be802..e2843fa60 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -46,7 +46,10 @@ async function getAchievementTemplates(select: TemplateSelectEnum): Promise>; async function getUserAchievementWithTemplate(id: number) { - return await prisma.user_achievement.findUnique({ + return await prisma.user_achievement.findUniqueOrThrow({ where: { id }, include: { template: true }, }); @@ -35,7 +35,7 @@ export type DefaultBucket = { // Bucket containing events from a specific time frame export type TimeBucket = { kind: 'time'; - relation: string; + relation?: string; startTime: Date; endTime: Date; }; @@ -51,7 +51,7 @@ export type BucketEventsWithAggr = BucketEvents & { }; // The recordValue is used as a reference for the time bucket creator on how many buckets to create. if the recordValue is 5, then 6 buckets will be created to check the last 6 weeks / monthes -export type BucketCreatorContext = { recordValue: number; context: AchievementContextType }; +export type BucketCreatorContext = { recordValue?: number; context: AchievementContextType }; type BucketFormulaFunction = (bucketContext: BucketCreatorContext) => BucketConfig; export type BucketFormula = { @@ -113,17 +113,17 @@ export type RelationTypes = 'match' | 'subcourse' | 'global_match' | 'global_sub export type ContextLecture = Pick; export type ContextMatch = { id: number; - relation: string | null; // will be null if searching for all matches + relation?: string; // will be null if searching for all matches lecture: ContextLecture[]; }; export type ContextSubcourse = { id: number; - relation: string | null; // will be null if searching for all subcourses + relation?: string; // will be null if searching for all subcourses lecture: ContextLecture[]; }; export type AchievementContextType = { user?: User; - match?: ContextMatch[]; - subcourse?: ContextSubcourse[]; + match: ContextMatch[]; + subcourse: ContextSubcourse[]; }; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 2f0ac331a..53811d361 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -31,12 +31,14 @@ function getRelationTypeAndId(relation: string): [type: RelationTypes, id: strin return [relationType as RelationTypes, id]; } +type WhereInput = Prisma.matchWhereInput | Prisma.subcourseWhereInput; + export async function getBucketContext(userID: string, relation?: string): Promise { const [userType, id] = getUserTypeAndIdForUserId(userID); - const whereClause = {}; + const whereClause: WhereInput = {}; - let relationType = null; + let relationType: string | null = null; if (relation) { const [relationTypeTmp, relationId] = getRelationTypeAndId(relation); relationType = relationTypeTmp; @@ -48,7 +50,7 @@ export async function getBucketContext(userID: string, relation?: string): Promi logger.info('evaluate bucket configuration', { userType, relation, relationType, whereClause }); - let matches = []; + let matches: any[] = []; if (!relationType || relationType === 'match') { matches = await prisma.match.findMany({ where: { ...whereClause, [`${userType}Id`]: id }, @@ -59,7 +61,7 @@ export async function getBucketContext(userID: string, relation?: string): Promi }); } - let subcourses = []; + let subcourses: any[] = []; if (!relationType || relationType === 'subcourse') { const userClause = userType === 'student' @@ -79,22 +81,23 @@ export async function getBucketContext(userID: string, relation?: string): Promi const achievementContext: AchievementContextType = { match: matches.map((match) => ({ id: match.id, - relation: relationType ? `${relationType}/${match.id}` : null, + relation: relationType ? `${relationType}/${match.id}` : undefined, lecture: match.lecture, })), subcourse: subcourses.map((subcourse) => ({ id: subcourse.id, - relation: relationType ? `${relationType}/${subcourse.id}` : null, + relation: relationType ? `${relationType}/${subcourse.id}` : undefined, lecture: subcourse.lecture, })), }; return achievementContext; } -export function transformPrismaJson(user: User, json: Prisma.JsonValue): AchievementContextType | null { - const transformedJson: AchievementContextType = { user: user }; +export function transformPrismaJson(user: User, json: Prisma.JsonObject): AchievementContextType { + // TODO: find proper type? + const transformedJson: any = { user: user }; if (json['relation']) { - const [relationType, relationId] = getRelationTypeAndId(json['relation']); + const [relationType, relationId] = getRelationTypeAndId(json['relation'] as string); transformedJson[`${relationType}Id`] = relationId; } const keys = Object.keys(json) || []; @@ -108,7 +111,7 @@ export function renderAchievementWithContext( userAchievement: user_achievement & { template: achievement_template }, achievementContext: AchievementContextType ): achievement_template { - const currentAchievementContext = userAchievement.template; + const currentAchievementContext = userAchievement.template as any; const templateKeys = Object.keys(userAchievement.template); templateKeys.forEach((key) => { const updatedElement = @@ -117,7 +120,7 @@ export function renderAchievementWithContext( : currentAchievementContext[key]; currentAchievementContext[key] = updatedElement; }); - return currentAchievementContext; + return currentAchievementContext as achievement_template; } export function getAchievementState(userAchievements: user_achievement[], currentAchievementIndex: number) { @@ -134,7 +137,7 @@ export function sortActionTemplatesToGroups(templatesForAction: achievement_temp if (!templatesByGroups.has(template.group)) { templatesByGroups.set(template.group, []); } - templatesByGroups.get(template.group).push(template); + templatesByGroups.get(template.group)!.push(template); } templatesByGroups.forEach((group, key) => { group.sort((a, b) => a.groupOrder - b.groupOrder); @@ -142,3 +145,7 @@ export function sortActionTemplatesToGroups(templatesForAction: achievement_temp }); return templatesByGroups; } + +export function isDefined(arugment: T | undefined | null): arugment is T { + return arugment !== undefined && arugment !== null; +} diff --git a/common/logger/logger.ts b/common/logger/logger.ts index f5680ab39..00eccb2a9 100644 --- a/common/logger/logger.ts +++ b/common/logger/logger.ts @@ -150,7 +150,7 @@ export class Logger { } // Error Logs should be used for unexpected errors, as they trigger alerts - error(message: string, err?: Error, args: LogData = {}): void { + error(message: string, err?: Error | null, args: LogData = {}): void { this.enrich(); // In order to use the datadog error tracking feature, we have to attach the error details to the root of the log message. // Unfortunately, in log4js this is only possible by adding it as context, otherwise, it would end up in .data. diff --git a/graphql/types/achievement.ts b/graphql/types/achievement.ts index 4e50ec2e2..951c0baca 100644 --- a/graphql/types/achievement.ts +++ b/graphql/types/achievement.ts @@ -40,7 +40,7 @@ class Achievement { alternativeText: string; @Field(() => achievement_action_type_enum, { nullable: true }) - actionType?: achievement_action_type_enum; + actionType?: achievement_action_type_enum | null; @Field(() => achievement_type_enum) achievementType: achievement_type_enum; @@ -49,7 +49,7 @@ class Achievement { achievementState: achievement_state; @Field(() => [Step], { nullable: true }) - steps?: Step[]; + steps?: Step[] | null; @Field(() => Int) maxSteps: number; @@ -58,16 +58,16 @@ class Achievement { currentStep?: number; @Field({ nullable: true }) - isNewAchievement?: boolean; + isNewAchievement?: boolean | null; @Field({ nullable: true }) - progressDescription?: string; + progressDescription?: string | null; @Field({ nullable: true }) - actionName?: string; + actionName?: string | null; @Field({ nullable: true }) - actionRedirectLink?: string; + actionRedirectLink?: string | null; } @ObjectType() @@ -76,7 +76,7 @@ class Step { name: string; @Field({ nullable: true }) - isActive?: boolean; + isActive?: boolean | null; } export { Achievement, Step, achievement_state }; From 46df9ed05477768996cf5fbf4ad60a4333086b91 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 27 Jan 2024 13:27:30 +0100 Subject: [PATCH 49/58] fix: check access before update --- graphql/user/mutations.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/graphql/user/mutations.ts b/graphql/user/mutations.ts index d3ce635ce..a3762471e 100644 --- a/graphql/user/mutations.ts +++ b/graphql/user/mutations.ts @@ -76,11 +76,15 @@ export class MutateUserResolver { @Mutation(() => Boolean) @AuthorizedDeferred(Role.ADMIN, Role.OWNER) async markAchievementAsSeen(@Ctx() context: GraphQLContext, @Arg('achievementId') achievementId: number) { - const acheivement = await prisma.user_achievement.update({ + const achievement = await prisma.user_achievement.findFirstOrThrow({ + where: { id: achievementId }, + }); + await hasAccess(context, 'User_achievement', achievement); + + await prisma.user_achievement.update({ where: { id: achievementId }, data: { isSeen: true }, }); - await hasAccess(context, 'User_achievement', acheivement); return true; } } From 1838fccc1f809002fd2727525ae8ece8fc28e338 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sat, 27 Jan 2024 15:17:55 +0100 Subject: [PATCH 50/58] chore: remove redundant metrics field from achievement template (#968) --- common/achievement/template.spec.ts | 134 ++++++++++++++++++ common/achievement/template.ts | 67 +++++---- integration-tests/15_achievements.ts | 50 +++---- .../migration.sql | 8 ++ prisma/schema.prisma | 1 - seed-db.ts | 34 +---- 6 files changed, 208 insertions(+), 86 deletions(-) create mode 100644 common/achievement/template.spec.ts create mode 100644 prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql diff --git a/common/achievement/template.spec.ts b/common/achievement/template.spec.ts new file mode 100644 index 000000000..8d8a8b05d --- /dev/null +++ b/common/achievement/template.spec.ts @@ -0,0 +1,134 @@ +import { achievement_template, achievement_type_enum } from '@prisma/client'; +import { prismaMock } from '../../jest/singletons'; +import { getAchievementTemplates, purgeAchievementTemplateCache, TemplateSelectEnum } from './template'; +import { ConditionDataAggregations } from './types'; + +function getRandomInt(max: number) { + return Math.floor(Math.random() * max); +} + +function createTestTemplate(group: string, metrics: string[]): achievement_template { + const dataAggr: ConditionDataAggregations = {}; + for (const metricIdx in metrics) { + dataAggr[`metric_${metricIdx}`] = { + metric: metrics[metricIdx], + aggregator: 'test', + }; + } + return { + id: getRandomInt(10000), + name: 'test', + templateFor: 'Global', + group, + groupOrder: 1, + stepName: 'test', + type: achievement_type_enum.TIERED, + subtitle: 'test', + description: 'test', + image: 'test', + achievedImage: 'test', + actionName: null, + actionRedirectLink: null, + actionType: null, + achievedText: null, + condition: '', + conditionDataAggregations: dataAggr, + isActive: true, + }; +} + +describe('test build group cache', () => { + const tests: { + name: string; + expectedGroups: { name: string; size: number }[]; + templates: achievement_template[]; + }[] = [ + { + name: 'should find a single group with a single template', + expectedGroups: [{ name: 'group1', size: 1 }], + templates: [createTestTemplate('group1', [])], + }, + { + name: 'should find a single group with multiple templates', + expectedGroups: [{ name: 'group1', size: 3 }], + templates: [createTestTemplate('group1', []), createTestTemplate('group1', []), createTestTemplate('group1', [])], + }, + { + name: 'should find multiple groups with multiple templates', + expectedGroups: [ + { name: 'group1', size: 2 }, + { name: 'group2', size: 1 }, + ], + templates: [createTestTemplate('group1', []), createTestTemplate('group1', []), createTestTemplate('group2', [])], + }, + { + name: 'should not find any group if there are no templates', + expectedGroups: [], + templates: [], + }, + ]; + + it.each(tests)('$name', async ({ templates: mockTemplates, expectedGroups }) => { + purgeAchievementTemplateCache(); + prismaMock.achievement_template.findMany.mockResolvedValue(mockTemplates); + + const templates = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + expect(templates.size).toBe(expectedGroups.length); + for (const expectedGroup of expectedGroups) { + expect(templates.has(expectedGroup.name)).toBe(true); + expect(templates.get(expectedGroup.name)?.length).toBe(expectedGroup.size); + } + }); +}); + +describe('test build metrics cache', () => { + const tests: { + name: string; + expectedGroups: { name: string; size: number }[]; + templates: achievement_template[]; + }[] = [ + { + name: 'should find a single group with a single template', + expectedGroups: [{ name: 'metric1', size: 1 }], + templates: [createTestTemplate('n/a', ['metric1'])], + }, + { + name: 'should find a single group with multiple templates', + expectedGroups: [{ name: 'metric1', size: 3 }], + templates: [createTestTemplate('n/a', ['metric1']), createTestTemplate('n/a', ['metric1']), createTestTemplate('n/a', ['metric1'])], + }, + { + name: 'should find multiple groups with multiple templates', + expectedGroups: [ + { name: 'metric1', size: 2 }, + { name: 'metric2', size: 1 }, + ], + templates: [createTestTemplate('n/a', ['metric1']), createTestTemplate('n/a', ['metric1']), createTestTemplate('n/a', ['metric2'])], + }, + { + name: 'should find the same template for multiple metrics', + expectedGroups: [ + { name: 'metric1', size: 1 }, + { name: 'metric2', size: 1 }, + ], + templates: [createTestTemplate('n/a', ['metric1', 'metric2'])], + }, + { + name: 'should not find any group if there are no templates', + expectedGroups: [], + templates: [], + }, + ]; + + it.each(tests)('$name', async ({ templates: mockTemplates, expectedGroups }) => { + purgeAchievementTemplateCache(); + prismaMock.achievement_template.findMany.mockResolvedValue(mockTemplates); + + const templates = await getAchievementTemplates(TemplateSelectEnum.BY_METRIC); + expect(templates.size).toBe(expectedGroups.length); + for (const expectedGroup of expectedGroups) { + expect(templates.has(expectedGroup.name)).toBe(true); + expect(templates.get(expectedGroup.name)?.length).toBe(expectedGroup.size); + } + }); +}); diff --git a/common/achievement/template.ts b/common/achievement/template.ts index e2843fa60..588729cbd 100644 --- a/common/achievement/template.ts +++ b/common/achievement/template.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; // ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html import { getLogger } from '../logger/logger'; import { prisma } from '../prisma'; -import { Metric } from './types'; +import { ConditionDataAggregations, Metric } from './types'; import { achievement_template } from '@prisma/client'; const logger = getLogger('Achievement Template'); @@ -19,36 +19,53 @@ export function purgeAchievementTemplateCache() { achievementTemplates.clear(); } -async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { - if (!achievementTemplates.has(select)) { - achievementTemplates.set(select, new Map()); - - const templatesFromDB = await prisma.achievement_template.findMany({ - where: { isActive: true }, - }); - - for (const template of templatesFromDB) { - const selection = template[select]; - - if (Array.isArray(selection)) { - for (const value of selection) { - if (!achievementTemplates.get(select)?.has(value)) { - achievementTemplates.get(select)?.set(value, []); - } - achievementTemplates.get(select)?.get(value)?.push(template); - } - } else { - if (!achievementTemplates.get(select)?.has(selection)) { - achievementTemplates.get(select)?.set(selection, []); - } - achievementTemplates.get(select)?.get(selection)?.push(template); +async function buildCache() { + const templates = await prisma.achievement_template.findMany({ + where: { isActive: true }, + }); + + buildGroupCache(templates); + buildMetricCache(templates); + + logger.info(`Loaded ${templates.length} achievement templates into the cache`); +} + +function buildGroupCache(templates: achievement_template[]) { + achievementTemplates.set(TemplateSelectEnum.BY_GROUP, new Map()); + for (const template of templates) { + const group = template.group; + if (!achievementTemplates.get(TemplateSelectEnum.BY_GROUP)?.has(group)) { + achievementTemplates.get(TemplateSelectEnum.BY_GROUP)?.set(group, []); + } + achievementTemplates.get(TemplateSelectEnum.BY_GROUP)?.get(group)?.push(template); + } +} + +function buildMetricCache(templates: achievement_template[]) { + achievementTemplates.set(TemplateSelectEnum.BY_METRIC, new Map()); + + for (const template of templates) { + const dataAggr = template.conditionDataAggregations as ConditionDataAggregations; + + for (const aggr in dataAggr) { + const metric = dataAggr[aggr].metric; + if (!achievementTemplates.get(TemplateSelectEnum.BY_METRIC)?.has(metric)) { + achievementTemplates.get(TemplateSelectEnum.BY_METRIC)?.set(metric, []); } + achievementTemplates.get(TemplateSelectEnum.BY_METRIC)?.get(metric)?.push(template); } - logger.info(`Loaded ${templatesFromDB.length} achievement templates into the cache`); } +} + +async function getAchievementTemplates(select: TemplateSelectEnum): Promise> { + if (achievementTemplates.size === 0) { + await buildCache(); + } + if (!achievementTemplates.has(select)) { logger.warn(`No achievement templates were found in the database`, { select }); } + return achievementTemplates.get(select) ?? new Map(); } diff --git a/integration-tests/15_achievements.ts b/integration-tests/15_achievements.ts index e8a2e31af..f4d351745 100644 --- a/integration-tests/15_achievements.ts +++ b/integration-tests/15_achievements.ts @@ -9,6 +9,19 @@ import { Match } from '../graphql/generated'; import { _createFixedToken } from '../common/secret/token'; import assert from 'assert'; import { purgeAchievementTemplateCache } from '../common/achievement/template'; +import { achievement_with_template, ConditionDataAggregations } from '../common/achievement/types'; + +function findTemplateByMetric(achievements: achievement_with_template[], metric: string) { + for (const achievement of achievements) { + const dataAggr = achievement.template.conditionDataAggregations as ConditionDataAggregations; + for (const key in dataAggr) { + if (dataAggr[key].metric === metric) { + return achievement; + } + } + } + return null; +} async function createTemplates() { purgeAchievementTemplateCache(); @@ -245,14 +258,16 @@ void test('Reward student regular learning', async () => { await client.request(` mutation StudentJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } `); - const achievement = await prisma.user_achievement.findFirst({ + const allAchievements = await prisma.user_achievement.findMany({ where: { userId: user.userID, - template: { metrics: { has: metric } }, context: { path: ['relation'], equals: `match/${match.id}` }, }, + include: { template: true }, }); - assert.strictEqual(achievement.recordValue, 1); + const achievement = findTemplateByMetric(allAchievements, metric); + assert.notStrictEqual(achievement, null); + assert.strictEqual(achievement!.recordValue, 1); const date = new Date(); date.setDate(date.getDate() - 7); @@ -330,13 +345,15 @@ void test('Reward pupil regular learning', async () => { await client.request(` mutation PupilJoinMatchMeeting { matchMeetingJoin(matchId:${match.id}) } `); - const achievement = await prisma.user_achievement.findFirst({ + const achievements = await prisma.user_achievement.findMany({ where: { userId: user.userID, - template: { metrics: { has: metric } }, context: { path: ['relation'], equals: `match/${match.id}` }, }, + include: { template: true }, }); + const achievement = findTemplateByMetric(achievements, metric); + assert.notStrictEqual(achievement, null); assert.strictEqual(achievement.recordValue, 1); const date = new Date(); @@ -557,7 +574,6 @@ const createStudentOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_verified'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 1, @@ -578,7 +594,6 @@ const createStudentOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_appointment_booked'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 2, @@ -601,7 +616,6 @@ const createStudentOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 3, @@ -622,7 +636,6 @@ const createStudentOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_coc_success'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 4, @@ -643,7 +656,6 @@ const createStudentOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_coc_success'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 5, @@ -666,7 +678,6 @@ const createPupilOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_verified'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 1, @@ -687,7 +698,6 @@ const createPupilOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_appointment_booked'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 2, @@ -710,7 +720,6 @@ const createPupilOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 3, @@ -732,7 +741,6 @@ const createPupilOnboardingTemplates = async () => { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 4, @@ -755,7 +763,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '1. durchgeführter Termin', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 1, @@ -779,7 +786,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '3 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 2, @@ -803,7 +809,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '5 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 3, @@ -827,7 +832,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '10 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 4, @@ -851,7 +855,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '15 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 5, @@ -875,7 +878,6 @@ const createStudentConductedMatchAppointmentTemplates = async () => { await prisma.achievement_template.create({ data: { name: '25 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 6, @@ -901,7 +903,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '1. durchgeführter Termin', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 1, @@ -925,7 +926,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '3 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 2, @@ -949,7 +949,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '5 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 3, @@ -973,7 +972,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '10 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 4, @@ -997,7 +995,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '15 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 5, @@ -1021,7 +1018,6 @@ const createPupilConductedMatchMeetingTemplates = async () => { await prisma.achievement_template.create({ data: { name: '25 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 6, @@ -1047,7 +1043,6 @@ const createStudentRegularLearningTemplate = async () => { await prisma.achievement_template.create({ data: { name: 'Regelmäßiges Lernen', - metrics: ['student_match_learned_regular'], templateFor: achievement_template_for_enum.Match, group: 'student_match_regular_learning', groupOrder: 1, @@ -1078,7 +1073,6 @@ const createPupilRegularLearningTemplate = async () => { await prisma.achievement_template.create({ data: { name: 'Regelmäßiges Lernen', - metrics: ['pupil_match_learned_regular'], templateFor: achievement_template_for_enum.Match, group: 'pupil_match_regular_learning', groupOrder: 1, diff --git a/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql b/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql new file mode 100644 index 000000000..00dabeb3a --- /dev/null +++ b/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `metrics` on the `achievement_template` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "achievement_template" DROP COLUMN "metrics"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6595dad12..2557e1aac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,7 +28,6 @@ datasource db { model achievement_template { id Int @id @default(autoincrement()) name String @db.VarChar - metrics String[] templateFor achievement_template_for_enum // Achievements can // - cross-platform (i.e. for one user) diff --git a/seed-db.ts b/seed-db.ts index 48d6ec916..20607bc0c 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1,14 +1,12 @@ /* eslint-disable comma-dangle */ import 'reflect-metadata'; import { createHash, randomBytes } from 'crypto'; -import { hashPassword } from './common/util/hashing'; import { getNotifications, importMessageTranslations, importNotifications } from './common/notification/notification'; import { _createFixedToken, createPassword, verifyEmail } from './common/secret'; -import { userForStudent, userForPupil, updateUser, refetchPupil, refetchStudent, userForScreener } from './common/user'; +import { userForStudent, userForPupil, refetchPupil, refetchStudent, userForScreener } from './common/user'; import { getLogger } from './common/logger/logger'; -import { becomeTutee, registerPupil } from './common/pupil/registration'; +import { registerPupil } from './common/pupil/registration'; import { isDev, isTest } from './common/util/environment'; -import { updatePupil } from './graphql/pupil/mutations'; import { prisma } from './common/prisma'; import { becomeInstructor, becomeTutor, registerStudent } from './common/student/registration'; import { addInstructorScreening, addTutorScreening } from './common/student/screening'; @@ -16,7 +14,6 @@ import { createMatch } from './common/match/create'; import { TEST_POOL } from './common/match/pool'; import { createRemissionRequest } from './common/remission-request'; import { joinSubcourse, joinSubcourseWaitinglist } from './common/courses/participants'; -import { create as createCoC } from './common/certificate-of-conduct/certificateOfConduct'; import { addCourseInstructor, addSubcourseInstructor } from './common/courses/states'; import { createPupilMatchRequest, createStudentMatchRequest } from './common/match/request'; import { createCourseTag } from './common/courses/tags'; @@ -750,7 +747,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_verified'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 1, @@ -771,7 +767,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_appointment_booked'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 2, @@ -794,7 +789,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 3, @@ -815,7 +809,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_coc_success'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 4, @@ -836,7 +829,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['student_onboarding_coc_success'], templateFor: achievement_template_for_enum.Global, group: 'student_onboarding', groupOrder: 5, @@ -858,7 +850,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_verified'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 1, @@ -879,7 +870,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_appointment_booked'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 2, @@ -902,7 +892,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 3, @@ -924,7 +913,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Onboarding abschließen', - metrics: ['pupil_onboarding_screened'], templateFor: achievement_template_for_enum.Global, group: 'pupil_onboarding', groupOrder: 4, @@ -947,7 +935,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '1. durchgeführter Termin', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 1, @@ -977,7 +964,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '3 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 2, @@ -1007,7 +993,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '5 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 3, @@ -1037,7 +1022,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '10 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 4, @@ -1067,7 +1051,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '15 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 5, @@ -1097,7 +1080,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '25 durchgeführte Termine', - metrics: ['student_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'student_conduct_match_appointment', groupOrder: 6, @@ -1129,7 +1111,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '1. durchgeführter Termin', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 1, @@ -1159,7 +1140,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '3 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 2, @@ -1189,7 +1169,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '5 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 3, @@ -1219,7 +1198,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '10 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 4, @@ -1249,7 +1227,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '15 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 5, @@ -1279,7 +1256,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: '25 durchgeführte Termine', - metrics: ['pupil_conducted_match_appointment'], templateFor: achievement_template_for_enum.Global_Matches, group: 'pupil_conduct_match_appointment', groupOrder: 6, @@ -1311,7 +1287,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Regelmäßiges Lernen', - metrics: ['pupil_match_learned_regular'], templateFor: achievement_template_for_enum.Match, group: 'pupil_match_regular_learning', groupOrder: 1, @@ -1342,7 +1317,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Regelmäßiges Lernen', - metrics: ['student_match_learned_regular'], templateFor: achievement_template_for_enum.Match, group: 'student_match_regular_learning', groupOrder: 1, @@ -1373,7 +1347,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Kurs anbieten', - metrics: ['student_create_course'], templateFor: achievement_template_for_enum.Course, group: 'student_offer_course', groupOrder: 1, @@ -1399,7 +1372,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Kurs anbieten', - metrics: ['student_submit_course'], templateFor: achievement_template_for_enum.Course, group: 'student_offer_course', groupOrder: 2, @@ -1426,7 +1398,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Kurs anbieten', - metrics: ['student_approve_course'], templateFor: achievement_template_for_enum.Course, group: 'student_offer_course', groupOrder: 3, @@ -1453,7 +1424,6 @@ void (async function setupDevDB() { await prisma.achievement_template.create({ data: { name: 'Kurs anbieten', - metrics: ['student_approve_course'], templateFor: achievement_template_for_enum.Course, group: 'student_offer_course', groupOrder: 4, From 828684c65d8aaa3a85885dfed7caaf80e7d397a3 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 27 Jan 2024 15:44:23 +0100 Subject: [PATCH 51/58] remove condition replacement in get achievement --- common/achievement/get.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/common/achievement/get.ts b/common/achievement/get.ts index b0d4a042d..ad9821d75 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -19,8 +19,8 @@ type ThenArg = T extends PromiseLike ? U : T; export type achievements_with_template = ThenArg>; const getAchievementById = async (user: User, achievementId: number): Promise => { - const userAchievement = await prisma.user_achievement.findUniqueOrThrow({ - where: { id: achievementId }, + const userAchievement = await prisma.user_achievement.findFirstOrThrow({ + where: { id: achievementId, userId: user.userID }, include: { template: true }, }); const achievement = await assembleAchievementData([userAchievement], user); @@ -160,12 +160,6 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ const isNewAchievement = state === achievement_state.COMPLETED && !userAchievements[currentAchievementIndex].isSeen; - // TODO: check if this is needed? - const condition = - currentAchievementTemplate.condition.includes('recordValue') && userAchievements[currentAchievementIndex].recordValue !== null - ? currentAchievementTemplate.condition.replace('recordValue', (userAchievements[currentAchievementIndex].recordValue! + 1).toString()) - : currentAchievementTemplate.condition; - let maxValue: number = 0; let currentValue: number = 0; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { @@ -174,12 +168,11 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ const relation = currentAchievementContext['relation'] as string; const evaluationResult = await evaluateAchievement( user.userID, - condition, + currentAchievementTemplate.condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, userAchievements[currentAchievementIndex].recordValue || undefined, relation ); - // TODO: check if this will still include the recodValue if (evaluationResult) { currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); maxValue = From 0b313617752db8519bb3cf63716ed9c590ef6c87 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sat, 27 Jan 2024 16:01:29 +0100 Subject: [PATCH 52/58] always iterate over all subcourses --- graphql/course/mutations.ts | 46 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index 1c09bad67..ef097c7af 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -92,7 +92,6 @@ export class MutateCourseResolver { @Arg('course') data: PublicCourseEditInput ): Promise { const course = await getCourse(courseId); - const user = context.user; await hasAccess(context, 'Course', course); if (course.courseState === 'allowed') { @@ -110,27 +109,23 @@ export class MutateCourseResolver { const result = await prisma.course.update({ data, where: { id: courseId } }); logger.info(`Course (${result.id}) updated by Student (${context.user.studentId})`); - const subcourse = await prisma.subcourse.findFirst({ + const subcourses = await prisma.subcourse.findMany({ where: { courseId: courseId }, }); - const usersSubcourseAchievements = await prisma.user_achievement.findMany({ - where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` } }, - include: { template: true }, - }); - const subcourseAchievements = await Promise.all( - usersSubcourseAchievements.map(async (usersSubcourseAchievement) => { - return await prisma.user_achievement.findMany({ - where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, templateId: usersSubcourseAchievement.template.id }, - }); - }) - ); - for (const achievement of subcourseAchievements.flat()) { - const { context } = achievement; - context['courseName'] = result.name; - await prisma.user_achievement.update({ - where: { id: achievement.id }, - data: { context }, + + for (const subcourse of subcourses) { + const usersSubcourseAchievements = await prisma.user_achievement.findMany({ + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` } }, + include: { template: true }, }); + for (const achievement of usersSubcourseAchievements) { + const { context } = achievement; + context['courseName'] = result.name; + await prisma.user_achievement.update({ + where: { id: achievement.id }, + data: { context }, + }); + } } return result; @@ -221,17 +216,20 @@ export class MutateCourseResolver { await prisma.course.update({ data: { courseState: 'submitted' }, where: { id: courseId } }); logger.info(`Course (${courseId}) submitted by Student (${context.user.studentId})`); - const subcourse = await prisma.subcourse.findFirst({ + const subcourses = await prisma.subcourse.findMany({ where: { courseId: courseId }, include: { subcourse_instructors_student: { select: { student: true } } }, }); - if (subcourse && subcourse.subcourse_instructors_student) { - subcourse.subcourse_instructors_student.forEach(async (instructor) => { - await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { + for (const subcourse of subcourses) { + if (!subcourse.subcourse_instructors_student) { + continue; + } + for (const subcourseInstructor of subcourse.subcourse_instructors_student) { + await Notification.actionTaken(userForStudent(subcourseInstructor.student), 'instructor_course_submitted', { courseName: course.name, relation: `subcourse/${subcourse.id}`, }); - }); + } } return true; } From 0da49549f732ce9d3e49425487392e135575bb18 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sun, 28 Jan 2024 12:08:36 +0100 Subject: [PATCH 53/58] Remove group from ua (#969) cleanup: remove group & group order from user achievement --- common/achievement/create.ts | 2 - common/achievement/get.ts | 4 +- integration-tests/15_achievements.ts | 43 +++++++++++-------- .../migration.sql | 10 +++++ prisma/schema.prisma | 5 --- 5 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql diff --git a/common/achievement/create.ts b/common/achievement/create.ts index ada43cf42..472ccb2e2 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -94,8 +94,6 @@ async function createNextUserAchievement( const createdUserAchievement = await prisma.user_achievement.create({ data: { userId: userId, - group: nextStepTemplate.group, - groupOrder: nextStepTemplate.groupOrder, context: context ? context : Prisma.JsonNull, template: { connect: { id: nextStepTemplate.id } }, recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null, diff --git a/common/achievement/get.ts b/common/achievement/get.ts index ad9821d75..69e2c2d26 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -43,7 +43,7 @@ const getNextStepAchievements = async (user: User): Promise => { userAchievementGroups[key].push(ua); }); Object.keys(userAchievementGroups).forEach((groupName) => { - const group = userAchievementGroups[groupName].sort((a, b) => a.groupOrder - b.groupOrder); + const group = userAchievementGroups[groupName].sort((a, b) => a.template.groupOrder - b.template.groupOrder); group[group.length - 1].achievedAt && delete userAchievementGroups[groupName]; }); const achievements: Achievement[] = await generateReorderedAchievementData(userAchievementGroups, user); @@ -122,7 +122,7 @@ const generateReorderedAchievementData = async (groups: { [group: string]: achie const achievements = await Promise.all( groupKeys.map(async (key) => { const group = groups[key]; - const sortedGroupAchievements = group.sort((a, b) => a.groupOrder - b.groupOrder); + const sortedGroupAchievements = group.sort((a, b) => a.template.groupOrder - b.template.groupOrder); /** * This Assembles individual achievements for tiered milestones. Tiered achievements represent steps on the path to higher scores. * Unlike sequential achievements, each tier is processed separately and displayed on the frontend as a distinct achievement. diff --git a/integration-tests/15_achievements.ts b/integration-tests/15_achievements.ts index f4d351745..2731ee6f6 100644 --- a/integration-tests/15_achievements.ts +++ b/integration-tests/15_achievements.ts @@ -43,11 +43,11 @@ void test('Reward student onboarding achievement sequence', async () => { const studentOnboarding1 = await prisma.user_achievement.findFirst({ where: { - group: 'student_onboarding', - groupOrder: 1, achievedAt: { not: null }, userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 1 }, }, + include: { template: true }, }); assert.ok(studentOnboarding1); @@ -63,11 +63,11 @@ void test('Reward student onboarding achievement sequence', async () => { `); const studentOnboarding2 = await prisma.user_achievement.findFirst({ where: { - group: 'student_onboarding', - groupOrder: 3, achievedAt: { not: null }, userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 3 }, }, + include: { template: true }, }); assert.ok(studentOnboarding2); @@ -85,18 +85,18 @@ void test('Reward student onboarding achievement sequence', async () => { `); const studentOnboarding3 = await prisma.user_achievement.findFirst({ where: { - group: 'student_onboarding', - groupOrder: 4, achievedAt: { not: null }, userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 4 }, }, + include: { template: true }, }); const studentOnboarding4 = await prisma.user_achievement.findFirst({ where: { - group: 'student_onboarding', - groupOrder: 5, userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 5 }, }, + include: { template: true }, }); assert.ok(studentOnboarding3); assert.ok(studentOnboarding4); @@ -111,11 +111,11 @@ void test('Reward pupil onboarding achievement sequence', async () => { const pupilOnboarding1 = await prisma.user_achievement.findFirst({ where: { - group: 'pupil_onboarding', - groupOrder: 1, achievedAt: { not: null }, userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 1 }, }, + include: { template: true }, }); assert.ok(pupilOnboarding1); // Screening @@ -124,18 +124,18 @@ void test('Reward pupil onboarding achievement sequence', async () => { `); const pupilOnboarding2 = await prisma.user_achievement.findFirst({ where: { - group: 'pupil_onboarding', - groupOrder: 3, achievedAt: { not: null }, userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 3 }, }, + include: { template: true }, }); const pupilOnboarding3 = await prisma.user_achievement.findFirst({ where: { - group: 'pupil_onboarding', - groupOrder: 4, userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 4 }, }, + include: { template: true }, }); assert.ok(pupilOnboarding2); assert.ok(pupilOnboarding3); @@ -180,9 +180,10 @@ void test('Reward student conducted match appointment', async () => { const studentJoinedMatchMeetingAchievements = await prisma.user_achievement.findMany({ where: { - group: 'student_conduct_match_appointment', userId: user.userID, + template: { group: 'student_conduct_match_appointment' }, }, + include: { template: true }, }); assert.ok(studentJoinedMatchMeetingAchievements[0]); assert.notStrictEqual(studentJoinedMatchMeetingAchievements.length, 0); @@ -225,9 +226,10 @@ void test('Reward pupil conducted match appointment', async () => { `); const pupilJoinedMatchMeetingAchievements = await prisma.user_achievement.findMany({ where: { - group: 'pupil_conduct_match_appointment', userId: user.userID, + template: { group: 'pupil_conduct_match_appointment' }, }, + include: { template: true }, }); assert.ok(pupilJoinedMatchMeetingAchievements); assert.notStrictEqual(pupilJoinedMatchMeetingAchievements.length, 0); @@ -289,10 +291,11 @@ void test('Reward student regular learning', async () => { const studentMatchRegularLearningRecord = await prisma.user_achievement.findFirst({ where: { userId: user.userID, - group: 'student_match_regular_learning', achievedAt: { not: null }, recordValue: 2, + template: { group: 'student_match_regular_learning' }, }, + include: { template: true }, }); assert.ok(studentMatchRegularLearningRecord); @@ -310,10 +313,11 @@ void test('Reward student regular learning', async () => { const studentMatchRegularLearning = await prisma.user_achievement.findFirst({ where: { userId: user.userID, - group: 'student_match_regular_learning', achievedAt: null, recordValue: 2, + template: { group: 'student_match_regular_learning' }, }, + include: { template: true }, }); assert.ok(studentMatchRegularLearning); }); @@ -387,10 +391,11 @@ void test('Reward pupil regular learning', async () => { const pupilMatchRegularLearning = await prisma.user_achievement.findFirst({ where: { userId: user.userID, - group: 'pupil_match_regular_learning', achievedAt: null, recordValue: 2, + template: { group: 'pupil_match_regular_learning' }, }, + include: { template: true }, }); assert.ok(pupilMatchRegularLearning); }); diff --git a/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql b/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql new file mode 100644 index 000000000..ffbf90ef0 --- /dev/null +++ b/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `group` on the `user_achievement` table. All the data in the column will be lost. + - You are about to drop the column `groupOrder` on the `user_achievement` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "user_achievement" DROP COLUMN "group", +DROP COLUMN "groupOrder"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2557e1aac..1da4cb5fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,11 +72,6 @@ model user_achievement { templateId Int template achievement_template @relation(fields: [templateId], references: [id]) userId String - // the description of group and groupOrder can be found in the achievement_template. - // It needs to be stored in user_achievement, as the group contains templating. - // The correctly assigned achievement for a match / a course is therefore stored in the user_achievement. - group String @db.VarChar - groupOrder Int isSeen Boolean @default(false) // achievedAt == null => not achieved, achievedAt != null => achieved achievedAt DateTime? @db.Timestamp(6) From 7e8ce085fe468a64503016da654f727f4054c517 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sun, 28 Jan 2024 13:56:46 +0100 Subject: [PATCH 54/58] feature: add dedicated relation field to user achievement table (#970) We identify user achievements by the templateID, userID, and relation. Previously, the relation was embedded within the JSON context, making it difficult to use or query. This PR moves the relation to a dedicated field in the user achievement table, allowing for easier querying and the creation of a unique index. This ensures we avoid accidentally creating multiple achievements for the same template and relation. --- common/achievement/create.ts | 23 ++++++++----------- common/achievement/get.ts | 13 ++++++----- common/achievement/types.ts | 2 +- common/achievement/util.ts | 17 +++++++++++--- common/courses/states.ts | 2 +- graphql/course/mutations.ts | 2 +- graphql/subcourse/mutations.ts | 2 +- integration-tests/15_achievements.ts | 4 ++-- .../migration.sql | 5 ++++ prisma/schema.prisma | 2 ++ 10 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql diff --git a/common/achievement/create.ts b/common/achievement/create.ts index 472ccb2e2..2466d824c 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -4,27 +4,20 @@ import { prisma } from '../prisma'; import { TemplateSelectEnum, getAchievementTemplates } from './template'; import tracer from '../logger/tracing'; import { AchievementToCheck } from './types'; - -function createRelationContextFilter(context?: SpecificNotificationContext): Prisma.JsonFilter { - if (context && context.relation) { - return { path: ['relation'], equals: context.relation }; - } - return { path: ['relation'], equals: Prisma.AnyNull }; -} +import { transformEventContextToUserAchievementContext } from './util'; export async function findUserAchievement( templateId: number, userId: string, context?: SpecificNotificationContext ): Promise { - const contextFilter = createRelationContextFilter(context); const userAchievement = await prisma.user_achievement.findFirst({ where: { templateId, userId, - context: contextFilter, + relation: context?.relation, }, - select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, + select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true, relation: true }, }); return userAchievement; } @@ -54,14 +47,13 @@ async function _createAchievement(currentTemplate: achievem const templatesForGroup = templatesByGroup.get(currentTemplate.group)!.sort((a, b) => a.groupOrder - b.groupOrder); - const contextFilter = createRelationContextFilter(context); const userAchievementsByGroup = await prisma.user_achievement.findMany({ where: { template: { group: currentTemplate.group, }, userId, - context: contextFilter, + relation: context.relation, }, orderBy: { template: { groupOrder: 'asc' } }, }); @@ -85,6 +77,7 @@ async function createNextUserAchievement( if (templatesForGroup.length <= nextStepIndex) { return null; } + const nextStepTemplate = templatesForGroup[nextStepIndex]; const achievedAt = templatesForGroup.length - 1 === nextStepIndex && templatesForGroup[nextStepIndex].type === achievement_type_enum.SEQUENTIAL ? new Date() : null; @@ -94,12 +87,14 @@ async function createNextUserAchievement( const createdUserAchievement = await prisma.user_achievement.create({ data: { userId: userId, - context: context ? context : Prisma.JsonNull, + // This ensures that the relation will set to null even if context.relation is an empty string + relation: context.relation || null, + context: context ? transformEventContextToUserAchievementContext(context) : Prisma.JsonNull, template: { connect: { id: nextStepTemplate.id } }, recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null, achievedAt: achievedAt, }, - select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, + select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true, relation: true }, }); return createdUserAchievement; } diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 69e2c2d26..10901fa0b 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -35,8 +35,7 @@ const getNextStepAchievements = async (user: User): Promise => { }); const userAchievementGroups: { [groupRelation: string]: achievements_with_template } = {}; userAchievements.forEach((ua) => { - const relation = (ua.context as Prisma.JsonObject)['relation'] || null; - const key = relation ? `${ua.template.group}/${relation}` : ua.template.group; + const key = ua.relation ? `${ua.template.group}/${ua.relation}` : ua.template.group; if (!userAchievementGroups[key]) { userAchievementGroups[key] = []; } @@ -148,7 +147,11 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ let currentAchievementIndex = userAchievements.findIndex((ua) => !ua.achievedAt); currentAchievementIndex = currentAchievementIndex >= 0 ? currentAchievementIndex : userAchievements.length - 1; - const achievementContext = transformPrismaJson(user, userAchievements[currentAchievementIndex].context as Prisma.JsonObject); + const achievementContext = transformPrismaJson( + user, + userAchievements[currentAchievementIndex].relation, + userAchievements[currentAchievementIndex].context as Prisma.JsonObject + ); const currentAchievementTemplate = renderAchievementWithContext(userAchievements[currentAchievementIndex], achievementContext); const achievementTemplates = await prisma.achievement_template.findMany({ @@ -164,14 +167,12 @@ const assembleAchievementData = async (userAchievements: achievements_with_templ let currentValue: number = 0; if (currentAchievementTemplate.type === achievement_type_enum.STREAK || currentAchievementTemplate.type === achievement_type_enum.TIERED) { const dataAggregationKeys = Object.keys(currentAchievementTemplate.conditionDataAggregations as Prisma.JsonObject); - const currentAchievementContext = userAchievements[currentAchievementIndex].context as Prisma.JsonObject; - const relation = currentAchievementContext['relation'] as string; const evaluationResult = await evaluateAchievement( user.userID, currentAchievementTemplate.condition, currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, userAchievements[currentAchievementIndex].recordValue || undefined, - relation + userAchievements[currentAchievementIndex].relation || undefined ); if (evaluationResult) { currentValue = dataAggregationKeys.map((key) => evaluationResult.resultObject[key]).reduce((a, b) => a + b, 0); diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 478f38664..162153cd5 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -100,7 +100,7 @@ export type ActionEvent = { type ThenArg = T extends PromiseLike ? U : T; export type achievement_with_template = ThenArg>; -export type AchievementToCheck = Pick; +export type AchievementToCheck = Pick; export type EvaluationResult = { conditionIsMet: boolean; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 53811d361..f74f6e958 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -9,6 +9,7 @@ import { User, getUserTypeAndIdForUserId } from '../user'; import { renderTemplate } from '../../utils/helpers'; import { getLogger } from '../logger/logger'; import { RelationTypes, AchievementContextType } from './types'; +import { SpecificNotificationContext, ActionID } from '../notification/actions'; const logger = getLogger('Achievement'); @@ -93,12 +94,13 @@ export async function getBucketContext(userID: string, relation?: string): Promi return achievementContext; } -export function transformPrismaJson(user: User, json: Prisma.JsonObject): AchievementContextType { +export function transformPrismaJson(user: User, relation: string | null, json: Prisma.JsonObject): AchievementContextType { // TODO: find proper type? const transformedJson: any = { user: user }; - if (json['relation']) { - const [relationType, relationId] = getRelationTypeAndId(json['relation'] as string); + if (relation) { + const [relationType, relationId] = getRelationTypeAndId(relation); transformedJson[`${relationType}Id`] = relationId; + transformedJson['relation'] = relation; } const keys = Object.keys(json) || []; keys.forEach((key) => { @@ -149,3 +151,12 @@ export function sortActionTemplatesToGroups(templatesForAction: achievement_temp export function isDefined(arugment: T | undefined | null): arugment is T { return arugment !== undefined && arugment !== null; } + +export function transformEventContextToUserAchievementContext(ctx: SpecificNotificationContext): object { + // Copy the context to not mutate the original one. + const uaCtx = { ...ctx }; + // The relation will be stored directly in the user_achievement table. + // To make sure we are not misusing the one in the context, we delete it here. + delete uaCtx.relation; + return uaCtx; +} diff --git a/common/courses/states.ts b/common/courses/states.ts index bab5b54d2..0e646e0da 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -175,7 +175,7 @@ export async function cancelSubcourse(user: User, subcourse: Subcourse) { userId: { in: courseInstructors.map((instructor) => userForStudent(instructor.student).userID), }, - context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, + relation: `subcourse/${subcourse.id}`, }, }); } diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index ef097c7af..fc69c4f05 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -115,7 +115,7 @@ export class MutateCourseResolver { for (const subcourse of subcourses) { const usersSubcourseAchievements = await prisma.user_achievement.findMany({ - where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` } }, + where: { relation: `subcourse/${subcourse.id}` }, include: { template: true }, }); for (const achievement of usersSubcourseAchievements) { diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 39d69fc49..3f33d3a8c 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -145,7 +145,7 @@ export class MutateSubcourseResolver { await prisma.user_achievement.deleteMany({ where: { userId: `student/${studentId}`, - context: { path: ['relation'], equals: `subcourse/${subcourseId}` }, + relation: `subcourse/${subcourseId}`, }, }); diff --git a/integration-tests/15_achievements.ts b/integration-tests/15_achievements.ts index 2731ee6f6..367054608 100644 --- a/integration-tests/15_achievements.ts +++ b/integration-tests/15_achievements.ts @@ -263,7 +263,7 @@ void test('Reward student regular learning', async () => { const allAchievements = await prisma.user_achievement.findMany({ where: { userId: user.userID, - context: { path: ['relation'], equals: `match/${match.id}` }, + relation: `match/${match.id}`, }, include: { template: true }, }); @@ -352,7 +352,7 @@ void test('Reward pupil regular learning', async () => { const achievements = await prisma.user_achievement.findMany({ where: { userId: user.userID, - context: { path: ['relation'], equals: `match/${match.id}` }, + relation: `match/${match.id}`, }, include: { template: true }, }); diff --git a/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql b/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql new file mode 100644 index 000000000..b0db0874f --- /dev/null +++ b/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "user_achievement" ADD COLUMN "relation" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_achievement_relation_userId_templateId_key" ON "user_achievement"("relation", "userId", "templateId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1da4cb5fc..6bd4a495e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,7 @@ model achievement_template { model user_achievement { id Int @id @default(autoincrement()) + relation String? // the id of the relating achievement_template templateId Int template achievement_template @relation(fields: [templateId], references: [id]) @@ -78,6 +79,7 @@ model user_achievement { // Streaks: we have to store the highest record for streaks recordValue Int? context Json @db.Json + @@unique([relation, userId, templateId], name: "unique_user_achievement") } // Save (tracked) event as raw data in DB and evaluate at runtime From 6185dbe17e1dd82a257ece5bfd2bcc47d22ef7b2 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sun, 28 Jan 2024 14:01:31 +0100 Subject: [PATCH 55/58] fix: typos --- common/achievement/aggregator.ts | 2 -- common/achievement/evaluate.ts | 1 - common/achievement/get.ts | 2 +- common/achievement/types.ts | 4 ++-- common/achievement/util.ts | 4 ++-- graphql/authorizations.ts | 1 - graphql/index.ts | 2 +- 7 files changed, 6 insertions(+), 10 deletions(-) diff --git a/common/achievement/aggregator.ts b/common/achievement/aggregator.ts index e7ea8e4d8..6f48bec0e 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -12,14 +12,12 @@ export const aggregators: Aggregator = { }, count: { function: (elements): number => { - // TODO: evaluate if this assumption is correct return elements.filter((num) => num != 0).length; }, }, // this aggregator should be used to check if min one event exist in a bucket, i.e. if one event happend in one week / one month presenceOfEvents: { function: (elements): number => { - // TODO: evaluate if this assumption is correct return elements.filter((num) => num != 0).length > 0 ? 1 : 0; }, }, diff --git a/common/achievement/evaluate.ts b/common/achievement/evaluate.ts index cf9c85386..68871d72f 100644 --- a/common/achievement/evaluate.ts +++ b/common/achievement/evaluate.ts @@ -21,7 +21,6 @@ async function _evaluateAchievement( ): Promise { // We only care about metrics that are used for the data aggregation const metrics = Object.values(dataAggregation).map((entry) => entry.metric); - // filter: wenn wir eine richtige relation haben -> filtern nach relation const achievementEvents = await prisma.achievement_event.findMany({ where: { userId, diff --git a/common/achievement/get.ts b/common/achievement/get.ts index 10901fa0b..c3f6fa92c 100644 --- a/common/achievement/get.ts +++ b/common/achievement/get.ts @@ -49,7 +49,7 @@ const getNextStepAchievements = async (user: User): Promise => { return achievements; }; -// Inactive achievements are acheievements that are not yet existing but could be achieved in the future. +// Inactive achievements are achievements that are not yet existing but could be achieved in the future. // They are created for every template in a Tiered achievements group that is not yet used as a achievement for a specific user. const getFurtherAchievements = async (user: User): Promise => { const userAchievements = await prisma.user_achievement.findMany({ diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 162153cd5..3832a066e 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -21,7 +21,7 @@ export type Metric = { export type FormulaFunction = (context: SpecificNotificationContext) => number; -// Used to destinguish between different types of buckets +// Used to distinguish between different types of buckets export type GenericBucketConfig = { bucketKind: T['kind']; buckets: T[]; @@ -50,7 +50,7 @@ export type BucketEventsWithAggr = BucketEvents & { aggregation: number; }; -// The recordValue is used as a reference for the time bucket creator on how many buckets to create. if the recordValue is 5, then 6 buckets will be created to check the last 6 weeks / monthes +// The recordValue is used as a reference for the time bucket creator on how many buckets to create. if the recordValue is 5, then 6 buckets will be created to check the last 6 weeks / months export type BucketCreatorContext = { recordValue?: number; context: AchievementContextType }; type BucketFormulaFunction = (bucketContext: BucketCreatorContext) => BucketConfig; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index f74f6e958..29142cbd3 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -148,8 +148,8 @@ export function sortActionTemplatesToGroups(templatesForAction: achievement_temp return templatesByGroups; } -export function isDefined(arugment: T | undefined | null): arugment is T { - return arugment !== undefined && arugment !== null; +export function isDefined(argument: T | undefined | null): argument is T { + return argument !== undefined && argument !== null; } export function transformEventContextToUserAchievementContext(ctx: SpecificNotificationContext): object { diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts index c8f9abd45..c1f49c015 100644 --- a/graphql/authorizations.ts +++ b/graphql/authorizations.ts @@ -591,7 +591,6 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = { }), }, Lecture: { - // TODO: This is a workaround for presentation purposes fields: withPublicFields< Lecture, 'id' | 'start' | 'duration' | 'createdAt' | 'updatedAt' | 'title' | 'description' | 'appointmentType' | 'isCanceled' | 'matchId' | 'subcourseId' diff --git a/graphql/index.ts b/graphql/index.ts index ba2d4b13f..14f6e43e6 100644 --- a/graphql/index.ts +++ b/graphql/index.ts @@ -186,7 +186,7 @@ const schema = buildSchemaSync({ ExtendedFieldsCooperationResolver, MutateCooperationResolver, - /* Achievement */ + /* Admin */ AdminMutationsResolver, ], authChecker, From f1997dc53a1dda2534e1d6f8ef4fe482abd3ee75 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sun, 28 Jan 2024 14:29:28 +0100 Subject: [PATCH 56/58] move action inside publish subcourse --- common/courses/states.ts | 27 ++++++++++++--------------- graphql/subcourse/mutations.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/common/courses/states.ts b/common/courses/states.ts index 0e646e0da..f265ef009 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -1,4 +1,4 @@ -import { subcourse as Subcourse, course as Course, student as Student, course_coursestate_enum as CourseState } from '@prisma/client'; +import { subcourse as Subcourse, course as Course, student as Student, course_coursestate_enum as CourseState, Prisma, student } from '@prisma/client'; import { Decision } from '../util/decision'; import { prisma } from '../prisma'; import { getLogger } from '../logger/logger'; @@ -73,25 +73,13 @@ export async function allowCourse(course: Course, screeningComment: string | nul // assuming the subcourses are ready: const subcourses = await prisma.subcourse.findMany({ where: { courseId: course.id }, - include: { subcourse_instructors_student: { select: { student: true, subcourseId: true } } }, + include: { subcourse_instructors_student: { include: { student: true } } }, }); for (const subcourse of subcourses) { if (await canPublish(subcourse)) { await publishSubcourse(subcourse); } } - - await Promise.all( - subcourses - .map((subcourse) => subcourse.subcourse_instructors_student) - .flat() - .map(async (instructor) => { - await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { - courseName: course.name, - relation: `subcourse/${instructor.subcourseId}`, - }); - }) - ); } export async function denyCourse(course: Course, screeningComment: string | null) { @@ -121,7 +109,7 @@ export async function canPublish(subcourse: Subcourse): Promise { return { allowed: true }; } -export async function publishSubcourse(subcourse: Subcourse) { +export async function publishSubcourse(subcourse: Prisma.subcourseGetPayload<{ include: { subcourse_instructors_student: { include: { student: true } } } }>) { const can = await canPublish(subcourse); if (!can.allowed) { throw new Error(`Cannot Publish Subcourse(${subcourse.id}), reason: ${can.reason}`); @@ -134,6 +122,15 @@ export async function publishSubcourse(subcourse: Subcourse) { await sendPupilCoursePromotion(subcourse); logger.info(`Subcourse(${subcourse.id}) was automatically promoted`); } + + await Promise.all( + subcourse.subcourse_instructors_student.map((instructor) => + Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }) + ) + ); } /* ---------------- Subcourse Cancel ------------ */ diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 3f33d3a8c..bd288f3da 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -155,7 +155,10 @@ export class MutateSubcourseResolver { @Mutation((returns) => Boolean) @AuthorizedDeferred(Role.ADMIN, Role.OWNER) async subcoursePublish(@Ctx() context: GraphQLContext, @Arg('subcourseId') subcourseId: number): Promise { - const subcourse = await getSubcourse(subcourseId); + const subcourse = await prisma.subcourse.findUniqueOrThrow({ + where: { id: subcourseId }, + include: { subcourse_instructors_student: { include: { student: true } } }, + }); await hasAccess(context, 'Subcourse', subcourse); await publishSubcourse(subcourse); return true; @@ -251,7 +254,11 @@ export class MutateSubcourseResolver { const participantCount = await prisma.subcourse_participants_pupil.count({ where: { subcourseId: subcourse.id } }); if (participantCount >= subcourse.maxParticipants) { // Course is full, create one single place for the pupil - subcourse = await prisma.subcourse.update({ where: { id: subcourse.id }, data: { maxParticipants: { increment: 1 } }, include: { lecture: true } }); + subcourse = await prisma.subcourse.update({ + where: { id: subcourse.id }, + data: { maxParticipants: { increment: 1 } }, + include: { lecture: true, subcourse_instructors_student: false }, + }); } // Joining the subcourse will automatically remove the pupil from the waitinglist From c0fc30458cb825a3f67f5bf314caa34d67464703 Mon Sep 17 00:00:00 2001 From: Daniel Henkel <9447057+dhenkel92@users.noreply.github.com> Date: Sun, 28 Jan 2024 18:50:39 +0100 Subject: [PATCH 57/58] chore: squash achievement migrations (#971) During the implementation, we had updated the DB structure many times. This PR is squashing all migrations into one, as separate migrations don't provide a lot of value here. --- .../migration.sql | 11 ------- .../migration.sql | 8 ----- .../migration.sql | 10 ------ .../migration.sql | 5 --- .../migration.sql | 32 +++++++++++++++++++ 5 files changed, 32 insertions(+), 34 deletions(-) delete mode 100644 prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql delete mode 100644 prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql delete mode 100644 prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql delete mode 100644 prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql create mode 100644 prisma/migrations/20240128111430_update_achievement_schema/migration.sql diff --git a/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql b/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql deleted file mode 100644 index 0040a2ad0..000000000 --- a/prisma/migrations/20231123075042_add_achievements_achieved_image/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ -/* - Warnings: - - - Added the required column `achievedImage` to the `achievement_template` table without a default value. This is not possible if the table is not empty. - -*/ --- DropIndex -DROP INDEX "user_achievement_group_key"; - --- AlterTable -ALTER TABLE "achievement_template" ADD COLUMN "achievedImage" VARCHAR NOT NULL; diff --git a/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql b/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql deleted file mode 100644 index 00dabeb3a..000000000 --- a/prisma/migrations/20240127125634_remove_metrics_from_achievement_template/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `metrics` on the `achievement_template` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "achievement_template" DROP COLUMN "metrics"; diff --git a/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql b/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql deleted file mode 100644 index ffbf90ef0..000000000 --- a/prisma/migrations/20240128110055_remove_group_and_group_order_from_user_achievement/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `group` on the `user_achievement` table. All the data in the column will be lost. - - You are about to drop the column `groupOrder` on the `user_achievement` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "user_achievement" DROP COLUMN "group", -DROP COLUMN "groupOrder"; diff --git a/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql b/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql deleted file mode 100644 index b0db0874f..000000000 --- a/prisma/migrations/20240128111430_add_relation_as_db_field/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "user_achievement" ADD COLUMN "relation" TEXT; - --- CreateIndex -CREATE UNIQUE INDEX "user_achievement_relation_userId_templateId_key" ON "user_achievement"("relation", "userId", "templateId"); diff --git a/prisma/migrations/20240128111430_update_achievement_schema/migration.sql b/prisma/migrations/20240128111430_update_achievement_schema/migration.sql new file mode 100644 index 000000000..077b7ec5d --- /dev/null +++ b/prisma/migrations/20240128111430_update_achievement_schema/migration.sql @@ -0,0 +1,32 @@ +-- DropIndex +DROP INDEX "user_achievement_group_key"; +/* + Warnings: + + - You are about to drop the column `metrics` on the `achievement_template` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "achievement_template" DROP COLUMN "metrics"; +/* + Warnings: + + - Added the required column `achievedImage` to the `achievement_template` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "achievement_template" ADD COLUMN "achievedImage" VARCHAR NOT NULL; +/* + Warnings: + + - You are about to drop the column `group` on the `user_achievement` table. All the data in the column will be lost. + - You are about to drop the column `groupOrder` on the `user_achievement` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "user_achievement" DROP COLUMN "group", +DROP COLUMN "groupOrder"; +-- AlterTable +ALTER TABLE "user_achievement" ADD COLUMN "relation" TEXT; +-- CreateIndex +CREATE UNIQUE INDEX "user_achievement_relation_userId_templateId_key" ON "user_achievement"("relation", "userId", "templateId"); From 15741a70f5250c06a19502791756870869628c84 Mon Sep 17 00:00:00 2001 From: Daniel Henkel Date: Sun, 28 Jan 2024 19:08:15 +0100 Subject: [PATCH 58/58] move db actions to common achievement folder --- common/achievement/delete.ts | 21 +++++++++++++++++++++ common/achievement/update.ts | 23 +++++++++++++++++++++++ common/courses/states.ts | 13 +++---------- graphql/course/mutations.ts | 24 ++++-------------------- graphql/subcourse/mutations.ts | 8 ++------ 5 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 common/achievement/delete.ts create mode 100644 common/achievement/update.ts diff --git a/common/achievement/delete.ts b/common/achievement/delete.ts new file mode 100644 index 000000000..e802b868c --- /dev/null +++ b/common/achievement/delete.ts @@ -0,0 +1,21 @@ +import { userForStudent } from '../user'; +import { prisma } from '../prisma'; + +export async function deleteAchievementsForSubcourse(subcourseId: number) { + const courseInstructors = await prisma.subcourse_instructors_student.findMany({ where: { subcourseId }, select: { student: true } }); + await deleteCourseAchievementsForStudents( + subcourseId, + courseInstructors.map((instructor) => userForStudent(instructor.student).userID) + ); +} + +export async function deleteCourseAchievementsForStudents(subcourseId: number, studentIds: string[]) { + await prisma.user_achievement.deleteMany({ + where: { + userId: { + in: studentIds, + }, + relation: `subcourse/${subcourseId}`, + }, + }); +} diff --git a/common/achievement/update.ts b/common/achievement/update.ts new file mode 100644 index 000000000..5f7d9dd14 --- /dev/null +++ b/common/achievement/update.ts @@ -0,0 +1,23 @@ +import { course } from '@prisma/client'; +import { prisma } from '../prisma'; + +export async function updateAchievementCTXByCourse(newCourse: course) { + const subcourses = await prisma.subcourse.findMany({ + where: { courseId: newCourse.id }, + }); + + for (const subcourse of subcourses) { + const usersSubcourseAchievements = await prisma.user_achievement.findMany({ + where: { relation: `subcourse/${subcourse.id}` }, + include: { template: true }, + }); + for (const achievement of usersSubcourseAchievements) { + const { context } = achievement; + context['courseName'] = newCourse.name; + await prisma.user_achievement.update({ + where: { id: achievement.id }, + data: { context }, + }); + } + } +} diff --git a/common/courses/states.ts b/common/courses/states.ts index f265ef009..604d70078 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -1,4 +1,4 @@ -import { subcourse as Subcourse, course as Course, student as Student, course_coursestate_enum as CourseState, Prisma, student } from '@prisma/client'; +import { subcourse as Subcourse, course as Course, student as Student, course_coursestate_enum as CourseState, Prisma } from '@prisma/client'; import { Decision } from '../util/decision'; import { prisma } from '../prisma'; import { getLogger } from '../logger/logger'; @@ -22,6 +22,7 @@ import { User, userForStudent } from '../user'; import { addGroupAppointmentsOrganizer } from '../appointment/participants'; import { sendPupilCoursePromotion, sendSubcourseCancelNotifications } from './notifications'; import * as Notification from '../../common/notification'; +import { deleteAchievementsForSubcourse } from '../../common/achievement/delete'; const logger = getLogger('Course States'); @@ -166,15 +167,7 @@ export async function cancelSubcourse(user: User, subcourse: Subcourse) { await sendSubcourseCancelNotifications(course, subcourse); logger.info(`Subcourse (${subcourse.id}) was cancelled`); - const courseInstructors = await prisma.subcourse_instructors_student.findMany({ where: { subcourseId: subcourse.id }, select: { student: true } }); - await prisma.user_achievement.deleteMany({ - where: { - userId: { - in: courseInstructors.map((instructor) => userForStudent(instructor.student).userID), - }, - relation: `subcourse/${subcourse.id}`, - }, - }); + await deleteAchievementsForSubcourse(subcourse.id); } /* --------------- Modify Subcourse ------------------- */ diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index fc69c4f05..1af313bf7 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -1,4 +1,4 @@ -import { course_category_enum, user_achievement } from '@prisma/client'; +import { course_category_enum } from '@prisma/client'; import { UserInputError } from 'apollo-server-express'; import { getFile, removeFile } from '../files'; import { getLogger } from '../../common/logger/logger'; @@ -13,12 +13,13 @@ import { getCourse, getStudent, getSubcoursesForCourse } from '../util'; import { putFile, DEFAULT_BUCKET } from '../../common/file-bucket'; import * as Notification from '../../common/notification'; -import { course_schooltype_enum as CourseSchooltype, course_subject_enum as CourseSubject, course_coursestate_enum as CourseState } from '../generated'; +import { course_schooltype_enum as CourseSchooltype, course_subject_enum as CourseSubject } from '../generated'; import { ForbiddenError } from '../error'; import { addCourseInstructor, allowCourse, denyCourse, subcourseOver } from '../../common/courses/states'; import { getCourseImageKey } from '../../common/courses/util'; import { createCourseTag } from '../../common/courses/tags'; import { userForStudent } from '../../common/user'; +import { updateAchievementCTXByCourse } from '../../common/achievement/update'; @InputType() class PublicCourseCreateInput { @@ -109,24 +110,7 @@ export class MutateCourseResolver { const result = await prisma.course.update({ data, where: { id: courseId } }); logger.info(`Course (${result.id}) updated by Student (${context.user.studentId})`); - const subcourses = await prisma.subcourse.findMany({ - where: { courseId: courseId }, - }); - - for (const subcourse of subcourses) { - const usersSubcourseAchievements = await prisma.user_achievement.findMany({ - where: { relation: `subcourse/${subcourse.id}` }, - include: { template: true }, - }); - for (const achievement of usersSubcourseAchievements) { - const { context } = achievement; - context['courseName'] = result.name; - await prisma.user_achievement.update({ - where: { id: achievement.id }, - data: { context }, - }); - } - } + await updateAchievementCTXByCourse(result); return result; } diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index bd288f3da..e992074eb 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -18,6 +18,7 @@ import { chat_type } from '../generated'; import { markConversationAsReadOnly, removeParticipantFromCourseChat } from '../../common/chat/conversation'; import { sendPupilCoursePromotion } from '../../common/courses/notifications'; import * as Notification from '../../common/notification'; +import { deleteCourseAchievementsForStudents } from '../../common/achievement/delete'; const logger = getLogger('MutateCourseResolver'); @@ -142,12 +143,7 @@ export class MutateSubcourseResolver { } logger.info(`Student(${studentId}) was deleted from Subcourse(${subcourseId}) by User(${context.user.userID})`); - await prisma.user_achievement.deleteMany({ - where: { - userId: `student/${studentId}`, - relation: `subcourse/${subcourseId}`, - }, - }); + await deleteCourseAchievementsForStudents(subcourseId, [instructorUser.userID]); return true; }