Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Achievements #866

Merged
merged 73 commits into from
Jan 28, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
31d45ea
add Readme
LomyW Oct 20, 2023
65d68a1
Merge branch 'master' into feat/achievements
LomyW Oct 23, 2023
d9f4c2d
Merge branch 'master' into feat/achievements
LomyW Nov 2, 2023
4be6a2d
Merge branch 'master' into feat/achievements
LomyW Nov 6, 2023
ad03043
Merge branch 'master' into feat/achievements
LomyW Nov 13, 2023
c598ec5
Merge branch 'master' into feat/achievements
LomyW Nov 14, 2023
d26339b
feat: achievement.actionTaken() (#887)
LomyW Nov 15, 2023
ce66a59
feat: track achievement event (#892)
LomyW Nov 17, 2023
1118ead
Merge branch 'master' into feat/achievements
LomyW Nov 20, 2023
f543bde
Merge branch 'master' into feat/achievements
LomyW Nov 20, 2023
8fadc8c
Merge branch 'master' into feat/achievements
LomyW Nov 21, 2023
aaa8f0e
feat: achievement standard aggregators (#893)
LomyW Nov 27, 2023
ed1cf5e
Merge branch 'master' into feat/achievements
LomyW Nov 30, 2023
b4bfe24
Merge branch 'master' into feat/achievements
LomyW Dec 4, 2023
ce7257e
feat: evaluation of achievements (#898)
LomyW Dec 7, 2023
c59b3b9
feat: achievements resolver (#903)
LucasFalkowsky Dec 11, 2023
324d20e
feat: achievement notification (#924)
LucasFalkowsky Dec 12, 2023
3013751
chore: todos
LucasFalkowsky Dec 13, 2023
183fe89
chore: todos
LucasFalkowsky Dec 14, 2023
14ce44a
feat: invoke achievement action taken (#913)
LomyW Dec 14, 2023
c42ff66
Merge remote-tracking branch 'origin/master' into feat/achievements
LomyW Dec 18, 2023
6bfd232
feature: add tracing to achievement system (#935)
dhenkel92 Dec 19, 2023
1d384a3
fix: change requests
LucasFalkowsky Dec 19, 2023
108ea6f
fix: global achievements (#931)
LucasFalkowsky Dec 20, 2023
eeeafc7
fix: add relation to group keys
LucasFalkowsky Dec 20, 2023
75ea75b
fix: completed progress description
LucasFalkowsky Dec 20, 2023
3c8ba94
Create evaluate unit tests (#943)
dhenkel92 Jan 8, 2024
33a0c40
Merge branch 'master' into feat/achievements
dhenkel92 Jan 8, 2024
01d91c2
feature: use remote s3 URL for achievement images (#946)
dhenkel92 Jan 11, 2024
7e79a50
feat: achievement notifications (#945)
LomyW Jan 11, 2024
af827c4
fix: use proper subcourse relation
dhenkel92 Jan 11, 2024
a0cae61
feature: add _userActionTaken admin mutation to test gamification
dhenkel92 Jan 11, 2024
e23fc60
feature: add more logs to achievement system
dhenkel92 Jan 13, 2024
b7657de
fix: unit tests should work in ci
dhenkel92 Jan 14, 2024
b73b3dc
fix: get correct active achievement, fixed image alignment
LucasFalkowsky Jan 15, 2024
3f506bb
Merge branch 'master' into feat/achievements
LomyW Jan 16, 2024
b3cfa90
feat: subcourse meeting join
LucasFalkowsky Jan 19, 2024
ca14256
feat: conduct subcourse appointment metric and mutation adjustments
LucasFalkowsky Jan 19, 2024
429bb08
fix: unit tests should work in ci (#958)
dhenkel92 Jan 20, 2024
70831c4
Update common/achievement/util.ts
LucasFalkowsky Jan 22, 2024
692761d
Merge branch 'master' into feat/achievements
LomyW Jan 23, 2024
d729ed4
fix: change requests
LucasFalkowsky Jan 23, 2024
64f5837
fix: reduce function for template by metric check
LucasFalkowsky Jan 23, 2024
41392ec
Feat: offer course template (#951)
LucasFalkowsky Jan 23, 2024
ccdd4a2
fix: check for subcourses before course submit
LucasFalkowsky Jan 23, 2024
762f688
fix: create course achievement
LucasFalkowsky Jan 23, 2024
66f9248
some bugs
LomyW Jan 23, 2024
e2f6c12
fix: display right next step achievements
LucasFalkowsky Jan 23, 2024
a54293d
fix seed data
LomyW Jan 23, 2024
dfe7ed3
fix: add information to action
LucasFalkowsky Jan 23, 2024
4984c60
fix: update seed file
LucasFalkowsky Jan 23, 2024
e647477
fix: revert redirect link
LucasFalkowsky Jan 23, 2024
7b435fd
fix: lecture filtering
LucasFalkowsky Jan 24, 2024
c794919
fix final notification for sequentials
LomyW Jan 24, 2024
8d09d9c
Merge branch 'master' into feat/achievements
LomyW Jan 24, 2024
8f9afec
changes requested
LucasFalkowsky Jan 24, 2024
8f879da
iterate over all subcourses for update
dhenkel92 Jan 25, 2024
9462e8d
Use relation for identification (#965)
dhenkel92 Jan 25, 2024
89fbfce
fix: requested changes
LucasFalkowsky Jan 25, 2024
49dc8e5
feat: achievement integration tests (#956)
LucasFalkowsky Jan 25, 2024
98ed602
fix: unit tests
dhenkel92 Jan 27, 2024
a1d23f3
fix: achievement identification
dhenkel92 Jan 27, 2024
6c48d0d
fix: typesystem for achievements (#966)
dhenkel92 Jan 27, 2024
46df9ed
fix: check access before update
dhenkel92 Jan 27, 2024
1838fcc
chore: remove redundant metrics field from achievement template (#968)
dhenkel92 Jan 27, 2024
828684c
remove condition replacement in get achievement
dhenkel92 Jan 27, 2024
0b31361
always iterate over all subcourses
dhenkel92 Jan 27, 2024
0da4954
Remove group from ua (#969)
dhenkel92 Jan 28, 2024
7e8ce08
feature: add dedicated relation field to user achievement table (#970)
dhenkel92 Jan 28, 2024
6185dbe
fix: typos
dhenkel92 Jan 28, 2024
f1997dc
move action inside publish subcourse
dhenkel92 Jan 28, 2024
c0fc304
chore: squash achievement migrations (#971)
dhenkel92 Jan 28, 2024
15741a7
move db actions to common achievement folder
dhenkel92 Jan 28, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,5 @@ ZOOM_ACCOUNT_ID=
ZOOM_MEETING_SDK_CLIENT_ID=
ZOOM_MEETING_SDK_CLIENT_SECRET=

ZOOM_ACTIVE=
ZOOM_ACTIVE=
GAMIFICATION_ACTIVE=
32 changes: 31 additions & 1 deletion common/achievement/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,34 @@ type Aggregator = Record<string, AggregatorFunction>;

// 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.length;
},
},
// this aggregator should be used to check if min one event exist in a bucket, i.e. if one event happend in one week / one month
presenceOfEvents: {
function: (elements): number => {
return elements.length > 0 ? 1 : 0;
},
},
lastStreakLength: {
function: (elements): number => {
// 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;
},
},
};
82 changes: 80 additions & 2 deletions common/achievement/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,84 @@
import { BucketFormula } from './types';
import moment from 'moment';
import { BucketFormula, DefaultBucket, GenericBucketConfig, TimeBucket } from './types';

type BucketCreatorDefs = Record<string, BucketFormula>;

// 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<DefaultBucket> => {
return { bucketKind: 'default', buckets: [] };
},
},
by_lecture_start: {
function: (bucketContext): GenericBucketConfig<TimeBucket> => {
const { context } = bucketContext;

if (!context[context.type].lecture) {
return { bucketKind: 'time', buckets: [] };
}

return {
bucketKind: 'time',
buckets: context[context.type].lecture.map((lecture) => ({
kind: 'time',
startTime: moment(lecture.start).subtract(10, 'minutes').toDate(),
endTime: moment(lecture.start).add(lecture.duration, 'minutes').add(10, 'minutes').toDate(),
})),
};
},
},
by_weeks: {
function: (context): GenericBucketConfig<TimeBucket> => {
const { recordValue: weeks } = context;
// the buckets are created in a desc order
const today = moment();
const buckets: TimeBucket[] = [];

/*
This is to look at the last few weeks before the current event so that we can evaluate whether the streak has been interrupted for the last few weeks or whether we have a new record.
---
Why do we pass the `recordValue` as weeks / months?
Let's imagine our current record: 6
We now want to see if this record still exists. We want to know whether the last 7 weeks are correct, because the previous record was 6.
Now it doesn't matter how long the user was inactive or similar. As soon as only one bucket is found among these buckets (7 buckets) that contains nothing, we know that the record has not been surpassed.
*/
for (let i = 0; i < weeks + 1; i++) {
const weeksBefore = today.clone().subtract(i, 'week');
buckets.push({
kind: 'time',
startTime: weeksBefore.startOf('week').toDate(),
endTime: weeksBefore.endOf('week').toDate(),
});
}

return {
bucketKind: 'time',
buckets,
};
},
},
by_months: {
function: (context): GenericBucketConfig<TimeBucket> => {
const { recordValue: months } = context;

// the buckets are created in a desc order
const today = moment();
const buckets: TimeBucket[] = [];

for (let i = 0; i < months + 1; i++) {
const monthsBefore = today.clone().subtract(i, 'month');
buckets.push({
kind: 'time',
startTime: monthsBefore.startOf('month').toDate(),
endTime: monthsBefore.endOf('month').toDate(),
});
}

return {
bucketKind: 'time',
buckets,
};
},
},
};
96 changes: 96 additions & 0 deletions common/achievement/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Prisma } from '@prisma/client';
import { Achievement_template, JsonFilter, achievement_template_for_enum } from '../../graphql/generated';
import { ActionID, SpecificNotificationContext } from '../notification/actions';
import { prisma } from '../prisma';
import { TemplateSelectEnum, getAchievementTemplates } from './template';

async function findUserAchievement<ID extends ActionID>(templateId: number, userId: string, context: SpecificNotificationContext<ID>) {
const keys = context ? Object.keys(context) : [];
const userAchievement = await prisma.user_achievement.findFirst({
where: {
templateId,
userId,
AND: keys.map((key) => {
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
return {
context: {
path: key,
equals: context[key],
},
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
};
}),
},
select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true },
});
return userAchievement;
}

async function getOrCreateUserAchievement<ID extends ActionID>(template: Achievement_template, userId: string, context?: SpecificNotificationContext<ID>) {
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;
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
const existingUserAchievement = await findUserAchievement(template.id, userId, !isGlobal && context);
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
if (!existingUserAchievement) {
return await createAchievement(template, userId, context);
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
}
return existingUserAchievement;
}

async function createAchievement<ID extends ActionID>(currentTemplate: Achievement_template, userId: string, context: SpecificNotificationContext<ID>) {
const templatesByGroup = await getAchievementTemplates(TemplateSelectEnum.BY_GROUP);
const keys = Object.keys(context);
const userAchievementsByGroup = await prisma.user_achievement.findMany({
where: {
template: {
group: currentTemplate.group,
},
userId,
AND: keys.map((key) => {
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
return {
context: {
path: key,
equals: context[key],
},
};
}),
},
orderBy: { template: { groupOrder: 'asc' } },
});

const nextStepIndex = userAchievementsByGroup.length > 0 ? userAchievementsByGroup.findIndex((e) => e.groupOrder === currentTemplate.groupOrder) + 1 : 0;

const templatesForGroup = templatesByGroup.get(currentTemplate.group);
if (templatesForGroup && templatesForGroup.length > nextStepIndex) {
const createdUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex, userId, context);
return createdUserAchievement;
}
}

async function createNextUserAchievement<ID extends ActionID>(
templatesForGroup: Achievement_template[],
nextStepIndex: number,
userId: string,
context: SpecificNotificationContext<ID>
) {
const nextStepTemplate = templatesForGroup[nextStepIndex];
// Here a user template is created for the next template in the group. This is done to always have the data availible for the next step.
// This could mean to, for example, have the name of a match partner that is not yet availible due to a unfinished matching process.
if (nextStepTemplate && nextStepTemplate.isActive) {
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
const createdUserAchievement = await prisma.user_achievement.create({
data: {
userId: userId,
group: nextStepTemplate.group,
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
groupOrder: nextStepTemplate.groupOrder,
context: context ? context : Prisma.JsonNull,
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
template: { connect: { id: nextStepTemplate.id } },
recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null,
},
select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true },
});
return createdUserAchievement;
}
const nextUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex + 1, userId, context);
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
return nextUserAchievement;
}

export { getOrCreateUserAchievement, createAchievement };
114 changes: 114 additions & 0 deletions common/achievement/evaluate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Achievement_event } from '../../graphql/generated';
import { AchievementContextType, BucketConfig, BucketEvents, ConditionDataAggregations, EvaluationResult } from './types';
import { prisma } from '../prisma';
import { aggregators } from './aggregator';
import swan from '@onlabsorg/swan-js';
import { bucketCreatorDefs } from './bucket';
import { getLogger } from '../logger/logger';
import { getBucketContext } from './util';
const logger = getLogger('Achievement');

export async function evaluateAchievement(
condition: string,
dataAggregation: ConditionDataAggregations,
metrics: string[],
recordValue: number
): Promise<EvaluationResult> {
const achievementEvents = await prisma.achievement_event.findMany({ where: { metric: { in: metrics } }, orderBy: { createdAt: 'desc' } });
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved

const eventsByMetric: Record<string, Achievement_event[]> = {};
for (const event of achievementEvents) {
if (!eventsByMetric[event.metric]) {
eventsByMetric[event.metric] = [];
}
eventsByMetric[event.metric].push(event);
}

const resultObject: Record<string, number> = {};
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];
if (!eventsForMetric) {
continue;
}
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
// we take the relation from the first event, that posesses one, in order to create buckets from it, if needed
const relation = eventsForMetric.find((event) => event.relation)?.relation;

const bucketCreatorFunction = bucketCreatorDefs[bucketCreator].function;
const bucketAggregatorFunction = aggregators[bucketAggregator].function;

const aggregatorFunction = aggregators[aggregator].function;

if (!bucketCreatorFunction || !bucketAggregatorFunction || !aggregatorFunction) {
logger.error(
`No bucket creator or aggregator function found for ${bucketCreator}, ${aggregator} or ${bucketAggregator} during the evaluation of achievement`
);
return;
}

let bucketContext: AchievementContextType;
if (relation) {
bucketContext = await getBucketContext(relation);
}

const buckets = bucketCreatorFunction({ recordValue: recordValue, context: bucketContext });

const bucketEvents = createBucketEvents(eventsForMetric, buckets);
const bucketAggr = bucketEvents.map((bucketEvent) => bucketAggregatorFunction(bucketEvent.events.map((event) => event.value)));

const value = 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, bucketConfig);
case 'time':
return createTimeBuckets(events, bucketConfig);
}
dhenkel92 marked this conversation as resolved.
Show resolved Hide resolved
}

const createDefaultBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => {
return events.map((event) => ({
kind: 'default',
events: [event],
}));
};

const createTimeBuckets = (events: Achievement_event[], bucketConfig: BucketConfig): BucketEvents[] => {
const { buckets } = bucketConfig;
const bucketsWithEvents: BucketEvents[] = buckets.map((bucket) => {
// values will be sorted in a desc order
const filteredEvents = events.filter((event) => event.createdAt >= bucket.startTime && event.createdAt <= bucket.endTime);

return {
kind: bucket.kind,
startTime: bucket.startTime,
endTime: bucket.endTime,
events: filteredEvents,
};
});
return bucketsWithEvents;
};
Loading
Loading