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/.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/.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/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 946ef8871..6f48bec0e 100644 --- a/common/achievement/aggregator.ts +++ b/common/achievement/aggregator.ts @@ -4,4 +4,34 @@ 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: (elements): number => { + return elements.reduce((total, num) => total + num, 0); + }, + }, + count: { + function: (elements): number => { + 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.filter((num) => num != 0).length > 0 ? 1 : 0; + }, + }, + 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) { + break; + } + value += 1; + } + return value; + }, + }, +}; diff --git a/common/achievement/bucket.spec.ts b/common/achievement/bucket.spec.ts new file mode 100644 index 000000000..e378c45dd --- /dev/null +++ b/common/achievement/bucket.spec.ts @@ -0,0 +1,337 @@ +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 } }); + 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-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05: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-13T23:50: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:05: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-14T23:50:00.000Z').toDate(), + endTime: moment('2023-08-15T01:05: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-13T23:50: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:05: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-07-31T23:50:00.000Z').toDate(), + endTime: moment('2023-08-01T00:50:00.000Z').toDate(), + relation: 'match', + }, + { + kind: 'time', + 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-07T23:50:00.000Z').toDate(), + endTime: moment('2023-08-08T04:55:00.000Z').toDate(), + relation: 'subcourse', + }, + { + kind: 'time', + startTime: moment('2023-08-13T23:50:00.000Z').toDate(), + endTime: moment('2023-08-14T01:50: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-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: 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: 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, + }, + ], + }, + ]; + + 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-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: 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: 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, + }, + ], + }, + ]; + + 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 287c657d3..742b35026 100644 --- a/common/achievement/bucket.ts +++ b/common/achievement/bucket.ts @@ -1,6 +1,110 @@ -import { BucketFormula } from './types'; +import moment from 'moment'; +import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket, ContextMatch, ContextSubcourse, ContextLecture } from './types'; type BucketCreatorDefs = Record; +function createLectureBuckets(data: T): TimeBucket[] { + 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[] = filteredLectures.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(5, '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 = {}; +export const bucketCreatorDefs: BucketCreatorDefs = { + default: { + function: (): GenericBucketConfig => { + return { bucketKind: 'default', buckets: [] }; + }, + }, + by_lecture_start: { + function: (bucketContext): GenericBucketConfig => { + const { context } = bucketContext; + // 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: (bucketContext): GenericBucketConfig => { + const { recordValue: weeks } = bucketContext; + // the buckets are created in a desc order + const today = moment(); + const timeBucket: GenericBucketConfig = { + bucketKind: 'time', + 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. + --- + 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'); + timeBucket.buckets.push({ + kind: 'time', + relation: undefined, + startTime: weeksBefore.startOf('week').toDate(), + endTime: weeksBefore.endOf('week').toDate(), + }); + } + + return timeBucket; + }, + }, + by_months: { + function: (bucketContext): GenericBucketConfig => { + const { recordValue: months } = bucketContext; + + // the buckets are created in a desc order + const today = moment(); + const timeBucket: GenericBucketConfig = { + bucketKind: 'time', + 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: undefined, + startTime: monthsBefore.startOf('month').toDate(), + endTime: monthsBefore.endOf('month').toDate(), + }); + } + + return timeBucket; + }, + }, +}; diff --git a/common/achievement/create.ts b/common/achievement/create.ts new file mode 100644 index 000000000..2466d824c --- /dev/null +++ b/common/achievement/create.ts @@ -0,0 +1,104 @@ +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'; +import tracer from '../logger/tracing'; +import { AchievementToCheck } from './types'; +import { transformEventContextToUserAchievementContext } from './util'; + +export async function findUserAchievement( + templateId: number, + userId: string, + context?: SpecificNotificationContext +): Promise { + const userAchievement = await prisma.user_achievement.findFirst({ + where: { + templateId, + userId, + relation: context?.relation, + }, + select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true, relation: true }, + }); + return userAchievement; +} + +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 : undefined); + if (!existingUserAchievement) { + return await createAchievement(template, userId, context); + } + return existingUserAchievement; +} + +const createAchievement = tracer.wrap('achievement.createAchievement', _createAchievement); +async function _createAchievement(currentTemplate: achievement_template, userId: string, context: SpecificNotificationContext) { + const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP); + if (!templatesByGroup.has(currentTemplate.group)) { + return null; + } + + const templatesForGroup = templatesByGroup.get(currentTemplate.group)!.sort((a, b) => a.groupOrder - b.groupOrder); + + const userAchievementsByGroup = await prisma.user_achievement.findMany({ + where: { + template: { + group: currentTemplate.group, + }, + userId, + relation: context.relation, + }, + orderBy: { template: { groupOrder: 'asc' } }, + }); + + const nextStepIndex = userAchievementsByGroup.length > 0 ? templatesForGroup.findIndex((e) => e.groupOrder === currentTemplate.groupOrder) + 1 : 0; + + if (templatesForGroup && templatesForGroup.length > nextStepIndex) { + const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context); + return createdUserAchievement; + } + + return null; +} + +async function createNextUserAchievement( + templatesForGroup: achievement_template[], + nextStepIndex: number, + userId: string, + context: SpecificNotificationContext +) { + 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; + // 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) { + const createdUserAchievement = await prisma.user_achievement.create({ + data: { + userId: userId, + // 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, relation: true }, + }); + return createdUserAchievement; + } + return null; +} + +export { getOrCreateUserAchievement, createAchievement }; 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/evaluate.spec.ts b/common/achievement/evaluate.spec.ts new file mode 100644 index 000000000..165dce666 --- /dev/null +++ b/common/achievement/evaluate.spec.ts @@ -0,0 +1,405 @@ +import moment from 'moment-timezone'; +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, + dissolveReasons: [], + 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: [], + 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 new file mode 100644 index 000000000..68871d72f --- /dev/null +++ b/common/achievement/evaluate.ts @@ -0,0 +1,133 @@ +import { Achievement_event } from '../../graphql/generated'; +import { BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult, GenericBucketConfig, TimeBucket } 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'; +import tracer from '../logger/tracing'; + +const logger = getLogger('Achievement'); + +export const evaluateAchievement = tracer.wrap('achievement.evaluateAchievement', _evaluateAchievement); + +async function _evaluateAchievement( + userId: string, + condition: string, + dataAggregation: ConditionDataAggregations, + recordValue?: number, + relation?: string +): Promise { + // We only care about metrics that are used for the data aggregation + const metrics = Object.values(dataAggregation).map((entry) => entry.metric); + 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) { + if (!eventsByMetric[event.metric]) { + eventsByMetric[event.metric] = []; + } + eventsByMetric[event.metric].push(event); + } + + const resultObject: Record = {}; + if (recordValue !== undefined) { + resultObject['recordValue'] = recordValue; + } + + for (const key in dataAggregation) { + if (!dataAggregation[key]) { + continue; + } + 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] ?? []; + // 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 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; + + const aggregatorFunction = aggregators[aggregator].function; + + 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))); + + 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) + 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); + case 'time': + return createTimeBuckets(events, bucketConfig); + default: + return createDefaultBuckets(events); + } +} + +const createDefaultBuckets = (events: Achievement_event[]): BucketEvents[] => { + return events.map((event) => ({ + kind: 'default', + events: [event], + })); +}; + +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 + 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, + startTime: bucket.startTime, + endTime: bucket.endTime, + relation: bucket.relation, + events: filteredEvents, + }; + }); + return bucketsWithEvents; +}; diff --git a/common/achievement/get.ts b/common/achievement/get.ts new file mode 100644 index 000000000..c3f6fa92c --- /dev/null +++ b/common/achievement/get.ts @@ -0,0 +1,235 @@ +import { prisma } from '../prisma'; +import { achievement_type_enum, Prisma } from '@prisma/client'; +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({ + 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.findFirstOrThrow({ + where: { id: achievementId, userId: user.userID }, + 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, template: { type: achievement_type_enum.SEQUENTIAL } }, + include: { template: true }, + }); + const userAchievementGroups: { [groupRelation: string]: achievements_with_template } = {}; + userAchievements.forEach((ua) => { + const key = ua.relation ? `${ua.template.group}/${ua.relation}` : ua.template.group; + if (!userAchievementGroups[key]) { + userAchievementGroups[key] = []; + } + userAchievementGroups[key].push(ua); + }); + Object.keys(userAchievementGroups).forEach((groupName) => { + 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); + return achievements; +}; + +// 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({ + 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 dataAggr = template.conditionDataAggregations as Prisma.JsonObject; + const maxValue = Object.keys(dataAggr) + .map((key) => { + const val = dataAggr[key] as number; + return Number(val); + }) + .reduce((a, b) => a + b, 0); + const achievement: Achievement = { + id: template.id, + name: template.name, + subtitle: template.subtitle, + description: template.description, + image: getAchievementImageURL(template.image), + alternativeText: 'alternativeText', + actionType: template.actionType ?? undefined, + achievementType: template.type, + 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 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 getUserAchievementsWithTemplates(user); + const userAchievementGroups: { [group: string]: achievements_with_template } = {}; + 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; +}; + +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.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. + * 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) => { + const achievement: Achievement = await assembleAchievementData([groupAchievement], user); + return achievement; + }) + ); + } + const groupAchievement: Achievement = await assembleAchievementData(sortedGroupAchievements, user); + return [groupAchievement]; + }) + ); + 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].relation, + userAchievements[currentAchievementIndex].context as Prisma.JsonObject + ); + const currentAchievementTemplate = renderAchievementWithContext(userAchievements[currentAchievementIndex], achievementContext); + + const achievementTemplates = await prisma.achievement_template.findMany({ + where: { group: currentAchievementTemplate.group, isActive: true }, + orderBy: { groupOrder: 'asc' }, + }); + + const state: achievement_state = getAchievementState(userAchievements, currentAchievementIndex); + + const isNewAchievement = state === achievement_state.COMPLETED && !userAchievements[currentAchievementIndex].isSeen; + + 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 as Prisma.JsonObject); + const evaluationResult = await evaluateAchievement( + user.userID, + currentAchievementTemplate.condition, + currentAchievementTemplate.conditionDataAggregations as ConditionDataAggregations, + userAchievements[currentAchievementIndex].recordValue || undefined, + userAchievements[currentAchievementIndex].relation || undefined + ); + 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; + } + + // 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, + subtitle: currentAchievementTemplate.subtitle, + description: currentAchievementTemplate.description, + image: getAchievementImageURL(currentAchievementTemplate.image), + alternativeText: 'alternativeText', + actionType: currentAchievementTemplate.actionType, + achievementType: currentAchievementTemplate.type, + achievementState: state, + steps: currentAchievementTemplate.stepName + ? achievementTemplates + .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) { + return { + name: achievement.stepName, + isActive: index === currentAchievementIndex, + }; + } + return null; + }) + .filter(isDefined) + : null, + maxSteps: maxValue, + currentStep: currentValue, + isNewAchievement: isNewAchievement, + // TODO: take progressDescription from achievement template and when COMPLETED, take the achievedText from achievement template + progressDescription: userAchievements[currentAchievementIndex].achievedAt + ? 'Hurra! alle Termin(e) wurden abgeschlossen' + : `Noch ${maxValue - currentValue} Termin(e) bis zum Abschluss`, + actionName: currentAchievementTemplate.actionName, + actionRedirectLink: currentAchievementTemplate.actionRedirectLink, + }; +}; + +export { getUserAchievements, getFurtherAchievements, getNextStepAchievements, getAchievementById }; diff --git a/common/achievement/index.ts b/common/achievement/index.ts new file mode 100644 index 000000000..01b300272 --- /dev/null +++ b/common/achievement/index.ts @@ -0,0 +1,202 @@ +import { prisma } from '../prisma'; +import { User } from '../user'; +import { sortActionTemplatesToGroups } from './util'; +import { getLogger } from '../logger/logger'; +import { ActionID, SpecificNotificationContext } from '../notification/actions'; +import { getAchievementTemplates, getTemplatesByMetrics, TemplateSelectEnum } from './template'; +import { evaluateAchievement } from './evaluate'; +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 metricsForAction = getMetricsByAction(actionId); + const templatesForAction = await tracer.trace('achievement.getTemplatesByMetrics', () => getTemplatesByMetrics(metricsForAction)); + + if (templatesForAction.length === 0) { + 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); + + const actionEvent: ActionEvent = { + actionId, + at: new Date(), + user: user, + context, + }; + const isEventTracked = await tracer.trace('achievement.trackEvent', () => trackEvent(actionEvent)); + if (!isEventTracked) { + logger.warn(`Can't track action for user`, { actionId, userId: user.userID }); + return; + } + + for (const [groupName, group] of templatesByGroups) { + try { + await tracer.trace('achievement.evaluateAchievementGroups', async (span) => { + span?.setTag('achievement.group', groupName); + logger.info('evaluate achievement group', { groupName }); + + let achievementToCheck: AchievementToCheck | undefined = undefined; + for (const template of group) { + const userAchievement = await tracer.trace('achievement.getOrCreateUserAchievement', () => + getOrCreateUserAchievement(template, user.userID, context) + ); + 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, + }); + 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) + ); + } + logger.info('group evaluation done', { groupName }); + }); + } catch (e) { + logger.error(`Error occurred while checking achievement for user`, e as Error, { userId: user.userID }); + } + } +} + +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`, { actionId: event.actionId }); + return false; + } + + for (const metric of metricsForEvent) { + 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, + value: value, + action: event.actionId, + userId: event.user.userID, + relation: event.context.relation ?? '', + createdAt: event.at, + }, + }); + } + + return true; +} + +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 === null) { + // TODO: handle this case + return; + } + + if (evaluationResult.conditionIsMet) { + 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 rewardUser(evaluationResultValue, userAchievement, event); + await createAchievement(awardedAchievement.template, userAchievement.userId, event.context); + } else { + await prisma.user_achievement.update({ + where: { id: userAchievement.id }, + data: { achievedAt: null, isSeen: false }, + }); + } +} + +async function isAchievementConditionMet(achievement: UserAchievementTemplate, event: ActionEvent) { + const { + userId, + recordValue, + template: { condition, conditionDataAggregations }, + } = achievement; + if (!condition) { + logger.error(`No condition found for achievement`, undefined, { template: achievement.template.name, achievementId: achievement.id }); + return { conditionIsMet: false, 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 | null, userAchievement: UserAchievementTemplate, event: ActionEvent) { + let newRecordValue = null; + 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 }, + select: { id: true, userId: true, achievedAt: true, template: true }, + }); + + const { type, group, groupOrder } = updatedAchievement.template; + + 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. + */ + 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', { + 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; +} diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index b9f11bcd2..58c8365f9 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -1,4 +1,83 @@ -import { Metric } from './types'; +import { ActionID } from '../notification/actions'; +import { registerAllMetrics } from './metrics'; +import { FormulaFunction, 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[] = []; +// 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: FormulaFunction): Metric { + return { + metricName, + onActions, + formula, + }; +} + +const batchOfMetrics = [ + /* STUDENT ONBOARDING */ + createMetric('student_onboarding_verified', ['student_registration_verified_email'], () => { + return 1; + }), + //! relevant if calendly API is integrated + createMetric('student_onboarding_appointment_booked', ['student_calendly_appointment_booked'], () => { + return 1; + }), + createMetric('student_onboarding_screened', ['student_screening_appointment_done', 'tutor_screening_success', 'instructor_screening_success'], () => { + return 1; + }), + createMetric('student_onboarding_coc_success', ['student_coc_updated'], () => { + return 1; + }), + /* PUPIL ONBOARDING */ + createMetric('pupil_onboarding_verified', ['pupil_registration_verified_email'], () => { + return 1; + }), + //! 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; + }), + + /* 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; + }), + createMetric('student_match_learned_regular', ['student_joined_match_meeting'], () => { + return 1; + }), + + /* 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 + + // TODO: attendance and punctuality records only for pupils - actions: pupil_joined_match_meeting, pupil_joined_subcourse_meeting +]; + +export function registerAchievementMetrics() { + registerAllMetrics(batchOfMetrics); +} diff --git a/common/achievement/metrics.ts b/common/achievement/metrics.ts new file mode 100644 index 000000000..a1e59a3f0 --- /dev/null +++ b/common/achievement/metrics.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html +import { ActionID } from '../notification/actions'; +import { Metric } from './types'; + +const metricByName: Map = new Map(); +const metricsByAction: Map = new Map(); + +export function getMetricsByAction(actionId: ID): Metric[] { + return metricsByAction.get(actionId) || []; +} + +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) => { + registerMetric(metric); + }); +} 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 new file mode 100644 index 000000000..588729cbd --- /dev/null +++ b/common/achievement/template.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html +import { getLogger } from '../logger/logger'; +import { prisma } from '../prisma'; +import { ConditionDataAggregations, Metric } from './types'; +import { achievement_template } from '@prisma/client'; + +const logger = getLogger('Achievement Template'); + +export enum TemplateSelectEnum { + BY_GROUP = 'group', + BY_METRIC = 'metrics', +} + +// string == metricId, group +const achievementTemplates: Map> = new Map(); + +export function purgeAchievementTemplateCache() { + achievementTemplates.clear(); +} + +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); + } + } +} + +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(); +} + +async function getTemplatesByMetrics(metricsForAction: Metric[]) { + const templatesByMetric = await getAchievementTemplates(TemplateSelectEnum.BY_METRIC); + 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[] = []; + if (!metricsForAction || !templatesByMetric) { + return []; + } + for (const metric of metricsForAction) { + const templatesForMetric = templatesByMetric.get(metric.metricName); + if (templatesForMetric) { + templatesForAction = [...templatesForAction, ...templatesForMetric]; + } + } + return templatesForAction; +} + +export { getAchievementTemplates, getTemplatesByMetrics }; diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 42a6e62eb..3832a066e 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -1,64 +1,129 @@ +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.findUniqueOrThrow({ + where: { id }, + include: { template: true }, + }); +} + 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; - }; +// Used to distinguish between different types of buckets +export type GenericBucketConfig = { + bucketKind: T['kind']; + buckets: T[]; }; +// Combines all possible bucket configs +export type BucketConfig = GenericBucketConfig | GenericBucketConfig; -export type FormulaFunction = (context: FormulaContext) => number; - -// A bucket is seen as a period of time -export interface Bucket { +export type DefaultBucket = { + kind: 'default'; +}; +// Bucket containing events from a specific time frame +export type TimeBucket = { + kind: 'time'; + relation?: string; startTime: Date; endTime: Date; -} +}; +// A bucket is seen as for a period of time +export type Bucket = DefaultBucket | TimeBucket; -export interface BucketEvents extends Bucket { - events: TrackEvent[]; -} -export interface BucketEventsWithAggr extends BucketEvents { - aggregation: EventValue; -} +export type BucketEvents = Bucket & { + events: achievement_event[]; +}; + +export type BucketEventsWithAggr = BucketEvents & { + aggregation: number; +}; -type BucketFormulaFunction = (context: FormulaContext) => Bucket[]; +// 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; 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 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; }; }; + +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; +}; + +type ThenArg = T extends PromiseLike ? U : T; +export type achievement_with_template = ThenArg>; +export type AchievementToCheck = Pick; + +export type EvaluationResult = { + conditionIsMet: boolean; + resultObject: Record; +}; + +// 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 ContextMatch = { + id: number; + relation?: string; // will be null if searching for all matches + lecture: ContextLecture[]; +}; +export type ContextSubcourse = { + id: number; + relation?: string; // will be null if searching for all subcourses + lecture: ContextLecture[]; +}; + +export type AchievementContextType = { + user?: User; + match: ContextMatch[]; + subcourse: ContextSubcourse[]; +}; 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/achievement/util.ts b/common/achievement/util.ts new file mode 100644 index 000000000..29142cbd3 --- /dev/null +++ b/common/achievement/util.ts @@ -0,0 +1,162 @@ +import 'reflect-metadata'; +// ↑ Needed by typegraphql: https://typegraphql.com/docs/installation.html +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 { renderTemplate } from '../../utils/helpers'; +import { getLogger } from '../logger/logger'; +import { RelationTypes, AchievementContextType } from './types'; +import { SpecificNotificationContext, ActionID } from '../notification/actions'; + +const logger = getLogger('Achievement'); + +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('/'); + if (!validRelationTypes.includes(relationType)) { + throw Error('No valid relation found in relation: ' + relationType); + } + 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: WhereInput = {}; + + let relationType: string | null = null; + if (relation) { + const [relationTypeTmp, relationId] = getRelationTypeAndId(relation); + relationType = relationTypeTmp; + + if (relationId) { + whereClause['id'] = Number(relationId); + } + } + + logger.info('evaluate bucket configuration', { userType, relation, relationType, whereClause }); + + let matches: any[] = []; + if (!relationType || relationType === 'match') { + matches = await prisma.match.findMany({ + where: { ...whereClause, [`${userType}Id`]: id }, + select: { + id: true, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${id}`] } } }, select: { start: true, duration: true } }, + }, + }); + } + + let subcourses: any[] = []; + if (!relationType || relationType === 'subcourse') { + 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: { + id: true, + lecture: { where: { NOT: { declinedBy: { hasSome: [`${userType}/${id}`] } } }, 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 = { + match: matches.map((match) => ({ + id: match.id, + relation: relationType ? `${relationType}/${match.id}` : undefined, + lecture: match.lecture, + })), + subcourse: subcourses.map((subcourse) => ({ + id: subcourse.id, + relation: relationType ? `${relationType}/${subcourse.id}` : undefined, + lecture: subcourse.lecture, + })), + }; + return achievementContext; +} + +export function transformPrismaJson(user: User, relation: string | null, json: Prisma.JsonObject): AchievementContextType { + // TODO: find proper type? + const transformedJson: any = { user: user }; + if (relation) { + const [relationType, relationId] = getRelationTypeAndId(relation); + transformedJson[`${relationType}Id`] = relationId; + transformedJson['relation'] = relation; + } + const keys = Object.keys(json) || []; + keys.forEach((key) => { + transformedJson[key] = json[key]; + }); + return transformedJson; +} + +export function renderAchievementWithContext( + userAchievement: user_achievement & { template: achievement_template }, + achievementContext: AchievementContextType +): achievement_template { + const currentAchievementContext = userAchievement.template as any; + 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 as achievement_template; +} + +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; +} + +export function isDefined(argument: T | undefined | null): argument is T { + return argument !== undefined && argument !== 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 2a50c8acc..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 } 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'; @@ -21,6 +21,8 @@ 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'; +import { deleteAchievementsForSubcourse } from '../../common/achievement/delete'; const logger = getLogger('Course States'); @@ -70,7 +72,10 @@ 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: { include: { student: true } } }, + }); for (const subcourse of subcourses) { if (await canPublish(subcourse)) { await publishSubcourse(subcourse); @@ -105,7 +110,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}`); @@ -118,6 +123,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 ------------ */ @@ -152,6 +166,8 @@ export async function cancelSubcourse(user: User, subcourse: Subcourse) { } await sendSubcourseCancelNotifications(course, subcourse); logger.info(`Subcourse (${subcourse.id}) was cancelled`); + + await deleteAchievementsForSubcourse(subcourse.id); } /* --------------- Modify Subcourse ------------------- */ @@ -231,5 +247,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/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/common/notification/actions.ts b/common/notification/actions.ts index 546e95504..99bcbff3a 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: { @@ -510,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: { @@ -618,6 +623,73 @@ const _notificationActions = { description: 'Screener suggests a Ressource for a User', sampleContext: {}, }, + + // 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: {}, + }, + + user_achievement_reward_issued: { + description: 'Reward issued', + sampleContext: { + achievement: { + id: '0', + name: 'achievement', + }, + }, + }, + student_calendly_appointment_booked: { + description: 'Student booked appointment via calendly ', + sampleContext: {}, + }, + + pupil_calendly_appointment_booked: { + description: 'Pupil booked appointment via calendly ', + sampleContext: {}, + }, + + /* MEETINGS */ + student_joined_match_meeting: { + description: 'Student joined a match meeting', + sampleContext: { + relation: 'match/1', + }, + }, + student_joined_subcourse_meeting: { + description: 'Student joined subcourse meeting', + sampleContext: { + relation: 'subcourse/1', + subcourseLecturesCount: '5', + }, + }, + pupil_joined_match_meeting: { + description: 'Pupil joined a match meeting', + sampleContext: { + relation: 'match/1', + }, + }, + pupil_joined_subcourse_meeting: { + description: 'Pupil joined subcourse meeting', + sampleContext: { + relation: 'subcourse/1', + subcourseLecturesCount: '5', + }, + }, TEST: { description: 'For Tests', sampleContext: { a: 'a' }, 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/index.ts b/common/notification/index.ts index ab40edf87..5c5c46d91 100644 --- a/common/notification/index.ts +++ b/common/notification/index.ts @@ -14,7 +14,9 @@ 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'; +// eslint-disable-next-line import/no-cycle +import * as Achievement from '../../common/achievement'; const logger = getLogger('Notification'); @@ -399,12 +401,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); - return await actionTakenAt(new Date(), user, actionId, notificationContext, false, noDuplicates, attachments); + if (!user.active) { + logger.debug(`No action '${actionId}' taken for User(${user.userID}) as the account is deactivated`); + return; + } + + 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 +450,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, diff --git a/common/notification/types.ts b/common/notification/types.ts index 6909ec3e4..12b539d36 100644 --- a/common/notification/types.ts +++ b/common/notification/types.ts @@ -46,6 +46,8 @@ export interface NotificationContextExtensions { // (i.e. when verifying an email change, or when testing mails) // BE CAREFUL: This might otherwise send an email with an auth token to someone else! overrideReceiverEmail?: Email; + // 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 { @@ -129,4 +131,5 @@ export enum NotificationType { COURSE = 'course', CERTIFICATE = 'certificate', LEGACY = 'legacy', + ACHIEVEMENT = 'achievement', } 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/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; + } } diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts index 1555c5fc6..c1f49c015 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 ------------------------------------------------------- */ @@ -342,7 +343,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, Job_run: { _all: nobody }, }; @@ -590,24 +591,25 @@ 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, - } - ), + 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/course/mutations.ts b/graphql/course/mutations.ts index f4943b55f..1af313bf7 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -11,12 +11,15 @@ 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 { 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 { @@ -106,6 +109,9 @@ export class MutateCourseResolver { } const result = await prisma.course.update({ data, where: { id: courseId } }); logger.info(`Course (${result.id}) updated by Student (${context.user.studentId})`); + + await updateAchievementCTXByCourse(result); + return result; } @@ -193,6 +199,22 @@ 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 subcourses = await prisma.subcourse.findMany({ + where: { courseId: courseId }, + include: { subcourse_instructors_student: { select: { student: true } } }, + }); + 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; } diff --git a/graphql/index.ts b/graphql/index.ts index a0700bd93..14f6e43e6 100644 --- a/graphql/index.ts +++ b/graphql/index.ts @@ -186,6 +186,7 @@ const schema = buildSchemaSync({ ExtendedFieldsCooperationResolver, MutateCooperationResolver, + /* Admin */ AdminMutationsResolver, ], authChecker, diff --git a/graphql/match/mutations.ts b/graphql/match/mutations.ts index d8efb69fb..7f5129cd1 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,24 @@ 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', { + relation: `match/${matchId}`, + }); + } else if (user.pupilId) { + await Notification.actionTaken(user, 'pupil_joined_match_meeting', { + relation: `match/${matchId}`, + }); + } + + 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/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 8b73e2df1..e992074eb 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -17,6 +17,8 @@ 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'; +import { deleteCourseAchievementsForStudents } from '../../common/achievement/delete'; const logger = getLogger('MutateCourseResolver'); @@ -99,6 +101,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 +116,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 +132,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,13 +142,19 @@ export class MutateSubcourseResolver { await removeParticipantFromCourseChat(instructorUser, subcourse.conversationId); } logger.info(`Student(${studentId}) was deleted from Subcourse(${subcourseId}) by User(${context.user.userID})`); + + await deleteCourseAchievementsForStudents(subcourseId, [instructorUser.userID]); + return true; } @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; @@ -240,7 +250,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 @@ -371,4 +385,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/types/achievement.ts b/graphql/types/achievement.ts new file mode 100644 index 000000000..951c0baca --- /dev/null +++ b/graphql/types/achievement.ts @@ -0,0 +1,82 @@ +import { ObjectType, Field, Int, registerEnumType } from 'type-graphql'; +import { achievement_type_enum, achievement_action_type_enum } from '@prisma/client'; + +enum achievement_state { + INACTIVE = 'INACTIVE', + ACTIVE = 'ACTIVE', + COMPLETED = 'COMPLETED', +} + +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() + 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 | null; + + @Field(() => achievement_type_enum) + achievementType: achievement_type_enum; + + @Field(() => achievement_state) + achievementState: achievement_state; + + @Field(() => [Step], { nullable: true }) + steps?: Step[] | null; + + @Field(() => Int) + maxSteps: number; + + @Field(() => Int) + currentStep?: number; + + @Field({ nullable: true }) + isNewAchievement?: boolean | null; + + @Field({ nullable: true }) + progressDescription?: string | null; + + @Field({ nullable: true }) + actionName?: string | null; + + @Field({ nullable: true }) + actionRedirectLink?: string | null; +} + +@ObjectType() +class Step { + @Field() + name: string; + + @Field({ nullable: true }) + isActive?: boolean | null; +} + +export { Achievement, Step, achievement_state }; diff --git a/graphql/user/fields.ts b/graphql/user/fields.ts index 5d8f29418..444c4141c 100644 --- a/graphql/user/fields.ts +++ b/graphql/user/fields.ts @@ -16,6 +16,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 { getAchievementById, getFurtherAchievements, getNextStepAchievements, getUserAchievements } from '../../common/achievement/get'; +import { Achievement } from '../types/achievement'; @ObjectType() export class UserContact implements UserContactType { @@ -209,6 +211,31 @@ 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) + async achievements(@Ctx() context: GraphQLContext): Promise { + const achievements = await getUserAchievements(context.user); + return achievements; + } + @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..a3762471e 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'; @@ -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,18 @@ export class MutateUserResolver { return true; } + @Mutation(() => Boolean) + @AuthorizedDeferred(Role.ADMIN, Role.OWNER) + async markAchievementAsSeen(@Ctx() context: GraphQLContext, @Arg('achievementId') achievementId: number) { + 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 }, + }); + return true; + } } 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..367054608 --- /dev/null +++ b/integration-tests/15_achievements.ts @@ -0,0 +1,1106 @@ +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'; +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(); + 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: { + achievedAt: { not: null }, + userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 1 }, + }, + include: { template: true }, + }); + 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: { + achievedAt: { not: null }, + userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 3 }, + }, + include: { template: true }, + }); + 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: { + achievedAt: { not: null }, + userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 4 }, + }, + include: { template: true }, + }); + const studentOnboarding4 = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + template: { group: 'student_onboarding', groupOrder: 5 }, + }, + include: { template: true }, + }); + 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: { + achievedAt: { not: null }, + userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 1 }, + }, + include: { template: true }, + }); + assert.ok(pupilOnboarding1); + // Screening + await adminClient.request(` + mutation RequestScreening { pupilCreateScreening(pupilId: ${pupil.pupil.id})} + `); + const pupilOnboarding2 = await prisma.user_achievement.findFirst({ + where: { + achievedAt: { not: null }, + userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 3 }, + }, + include: { template: true }, + }); + const pupilOnboarding3 = await prisma.user_achievement.findFirst({ + where: { + userId: user.userID, + template: { group: 'pupil_onboarding', groupOrder: 4 }, + }, + include: { template: true }, + }); + 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: { + userId: user.userID, + template: { group: 'student_conduct_match_appointment' }, + }, + include: { template: true }, + }); + 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: { + userId: user.userID, + template: { group: 'pupil_conduct_match_appointment' }, + }, + include: { template: true }, + }); + 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 allAchievements = await prisma.user_achievement.findMany({ + where: { + userId: user.userID, + relation: `match/${match.id}`, + }, + include: { template: true }, + }); + const achievement = findTemplateByMetric(allAchievements, metric); + assert.notStrictEqual(achievement, null); + 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, + achievedAt: { not: null }, + recordValue: 2, + template: { group: 'student_match_regular_learning' }, + }, + include: { template: true }, + }); + 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, + achievedAt: null, + recordValue: 2, + template: { group: 'student_match_regular_learning' }, + }, + include: { template: true }, + }); + 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 achievements = await prisma.user_achievement.findMany({ + where: { + userId: user.userID, + relation: `match/${match.id}`, + }, + include: { template: true }, + }); + const achievement = findTemplateByMetric(achievements, metric); + assert.notStrictEqual(achievement, null); + 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, + achievedAt: null, + recordValue: 2, + template: { group: 'pupil_match_regular_learning' }, + }, + include: { template: true }, + }); + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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(); 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 3823eb882..edd787210 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -15,17 +15,19 @@ import http from 'http'; // Ensure Notification hooks are always loaded import './../common/notification/hooks'; +import { registerAchievementMetrics } from '../common/achievement/metric'; //SETUP: logger 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 configureGracefulShutdown(scheduler); +registerAchievementMetrics(); // Add Metrics Server to Jobs Dyno async function startMetricsServer() { diff --git a/package-lock.json b/package-lock.json index 62bc5813b..e4bee7fe0 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", @@ -84,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", @@ -3702,6 +3704,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 +10359,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", @@ -10782,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", @@ -12712,8 +12757,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", @@ -14727,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", @@ -15167,6 +15220,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..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", @@ -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", @@ -111,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", @@ -163,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/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/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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 488a1ac9d..6bd4a495e 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) @@ -49,6 +48,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 @@ -68,21 +68,18 @@ 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]) 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 @unique @db.VarChar - groupOrder Int isSeen Boolean @default(false) // achievedAt == null => not achieved, achievedAt != null => achieved achievedAt DateTime? @db.Timestamp(6) // 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 @@ -1329,6 +1326,7 @@ enum notification_type_enum { course certificate legacy + achievement } enum important_information_language_enum { diff --git a/seed-db.ts b/seed-db.ts index 7c87b4e24..20607bc0c 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1,13 +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'; @@ -15,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'; @@ -26,6 +24,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 '@prisma/client'; const logger = getLogger('DevSetup'); @@ -743,6 +742,712 @@ void (async function setupDevDB() { }, }); + /* Achievements */ + // STUDENT ONBOARDING + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/empty_state.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_1.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_2.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_3.png', + achievedImage: '', + actionName: 'Zeugnis einreichen', + 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' } }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Onboarding abschließen', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/step_4.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/four_pieces/empty_state.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_1.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_2.png', + 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', + 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: 'gamification/achievements/tmp/finish_onboarding/three_pieces/step_3.png', + 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', + 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: 'gamification/achievements/tmp/x_lectures_held/one_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 1, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/three_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 3, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/five_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 5, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/ten_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 10, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/fifteen_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 15, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/twentyfive_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 25, + }, + }, + isActive: true, + }, + }); + + // PUPIL CONDUCTED MATCH APPOINTMENT + await prisma.achievement_template.create({ + data: { + name: '1. durchgeführter Termin', + 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: 'gamification/achievements/tmp/x_lectures_held/one_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 1, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '3 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/three_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 3, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '5 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/five_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 5, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '10 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/ten_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 10, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '15 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/fifteen_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 15, + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: '25 durchgeführte Termine', + 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: 'gamification/achievements/tmp/x_lectures_held/twentyfive_lectures_held.jpg', + 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', + createBuckets: 'by_lecture_start', + bucketAggregator: 'presenceOfEvents', + valueToAchieve: 25, + }, + }, + isActive: true, + }, + }); + + // PUPIL REGULAR LEARNING + await prisma.achievement_template.create({ + data: { + name: 'Regelmäßiges Lernen', + 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: 'gamification/achievements/tmp/finished_course_sucessfully/finished_course_sucessfully.jpg', + 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', + 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: 'gamification/achievements/tmp/finished_course_sucessfully/finished_course_sucessfully.jpg', + 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, + }, + }); + + // STUDENT OFFER COURSE + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + 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', + 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/{{subcourseId}}', + 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', + 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: null, + actionRedirectLink: null, + 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', + 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); diff --git a/types/custom.d.ts b/types/custom.d.ts new file mode 100644 index 000000000..c15b4e390 --- /dev/null +++ b/types/custom.d.ts @@ -0,0 +1,4 @@ +declare module '@onlabsorg/swan-js' { + declare function parse(condition: any): (context: any) => Promise; + export { parse }; +} 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'); +} 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')); diff --git a/web/index.ts b/web/index.ts index 2363dd758..1c5173eb1 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.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(); export const started = (async function main() { logger.info(`Starting the Webserver`);