diff --git a/common/achievement/create.ts b/common/achievement/create.ts index dc960a01c..c9c85afb6 100644 --- a/common/achievement/create.ts +++ b/common/achievement/create.ts @@ -1,4 +1,4 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, achievement_type_enum } from '@prisma/client'; import { Achievement_template, achievement_template_for_enum } from '../../graphql/generated'; import { ActionID, SpecificNotificationContext } from '../notification/actions'; import { prisma } from '../prisma'; @@ -88,6 +88,10 @@ async function createNextUserAchievement( return; } const nextStepTemplate = templatesForGroup[nextStepIndex]; + const achievedAt = + templatesForGroup.length === nextStepIndex && templatesForGroup[nextStepIndex].type === achievement_type_enum.SEQUENTIAL + ? JSON.stringify(new Date()) + : null; // Here a user template is created for the next template in the group. This is done to always have the data availible for the next step. // This could mean to, for example, have the name of a match partner that is not yet availible due to a unfinished matching process. if (nextStepTemplate) { @@ -99,13 +103,12 @@ async function createNextUserAchievement( context: context ? context : Prisma.JsonNull, template: { connect: { id: nextStepTemplate.id } }, recordValue: nextStepTemplate.type === 'STREAK' ? 0 : null, + achievedAt: achievedAt, }, select: { id: true, userId: true, context: true, template: true, achievedAt: true, recordValue: true }, }); return createdUserAchievement; } - const nextUserAchievement = await createNextUserAchievement(templatesForGroup, nextStepIndex + 1, userId, context); - return nextUserAchievement; } export { getOrCreateUserAchievement, createAchievement }; diff --git a/common/achievement/metric.ts b/common/achievement/metric.ts index 3d17e7de3..c2be5c44d 100644 --- a/common/achievement/metric.ts +++ b/common/achievement/metric.ts @@ -72,7 +72,16 @@ const batchOfMetrics = [ return 1; }), - // TODO: add offer course metric listening to 2 actions - screening_success and course_created + /* OFFER COURSE */ + createMetric('student_create_course', ['instructor_course_created'], () => { + return 1; + }), + createMetric('student_submit_course', ['instructor_course_submitted'], () => { + return 1; + }), + createMetric('student_approve_course', ['instructor_course_approved'], () => { + return 1; + }), // TODO: new match metric listening to 2 actions - screening_success and match_requested diff --git a/common/achievement/types.ts b/common/achievement/types.ts index 7b6d9e84e..c4309cd70 100644 --- a/common/achievement/types.ts +++ b/common/achievement/types.ts @@ -114,6 +114,6 @@ export type ContextSubcourse = { export type AchievementContextType = { user?: User; - match: ContextMatch[]; - subcourse: ContextSubcourse[]; + match?: ContextMatch[]; + subcourse?: ContextSubcourse[]; }; diff --git a/common/achievement/util.ts b/common/achievement/util.ts index 594104840..b29a0163e 100644 --- a/common/achievement/util.ts +++ b/common/achievement/util.ts @@ -94,14 +94,14 @@ export async function getBucketContext(userID: string, relation?: string): Promi } export function transformPrismaJson(user: User, json: Prisma.JsonValue): AchievementContextType | null { - if (!json['match'] && !json['subcourse']) { + const keys = Object.keys(json); + if (!keys) { return null; } - const transformedJson: AchievementContextType = { - user: user, - match: json['match'] ? json['match'] : undefined, - subcourse: json['subcourse'] ? json['subcourse'] : undefined, - }; + const transformedJson: AchievementContextType = { user: user }; + keys.forEach((key) => { + transformedJson[key] = json[key]; + }); return transformedJson; } export async function getUserAchievementWithTemplate(id: number) { diff --git a/common/courses/states.ts b/common/courses/states.ts index 2a50c8acc..b68decdd0 100644 --- a/common/courses/states.ts +++ b/common/courses/states.ts @@ -21,6 +21,7 @@ import { cancelAppointment } from '../appointment/cancel'; import { User, userForStudent } from '../user'; import { addGroupAppointmentsOrganizer } from '../appointment/participants'; import { sendPupilCoursePromotion, sendSubcourseCancelNotifications } from './notifications'; +import * as Notification from '../../common/notification'; const logger = getLogger('Course States'); @@ -70,12 +71,23 @@ export async function allowCourse(course: Course, screeningComment: string | nul // Usually when a new course is created, instructors also create a proper subcourse with it // and then forget to publish it after it was approved. Thus we just publish during approval, // assuming the subcourses are ready: - const subcourses = await prisma.subcourse.findMany({ where: { courseId: course.id } }); + const subcourses = await prisma.subcourse.findMany({ + where: { courseId: course.id }, + include: { subcourse_instructors_student: { select: { student: true } } }, + }); for (const subcourse of subcourses) { if (await canPublish(subcourse)) { await publishSubcourse(subcourse); } } + + const [subcourse] = subcourses; + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_approved', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }); + }); } export async function denyCourse(course: Course, screeningComment: string | null) { @@ -152,6 +164,16 @@ export async function cancelSubcourse(user: User, subcourse: Subcourse) { } await sendSubcourseCancelNotifications(course, subcourse); logger.info(`Subcourse (${subcourse.id}) was cancelled`); + + const courseInstructors = await prisma.subcourse_instructors_student.findMany({ where: { subcourseId: subcourse.id }, select: { student: true } }); + await prisma.user_achievement.deleteMany({ + where: { + userId: { + in: courseInstructors.map((instructor) => userForStudent(instructor.student).userID), + }, + context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, + }, + }); } /* --------------- Modify Subcourse ------------------- */ @@ -231,5 +253,10 @@ export async function addSubcourseInstructor(user: User | null, subcourse: Subco await addParticipant(newInstructorUser, subcourse.conversationId, subcourse.groupChatType as ChatType); } + const { name } = await prisma.course.findUnique({ where: { id: subcourse.courseId }, select: { name: true } }); + await Notification.actionTaken(userForStudent(newInstructor), 'instructor_course_created', { + courseName: name, + relation: `subcourse/${subcourse.id}`, + }); logger.info(`Student (${newInstructor.id}) was added as an instructor to Subcourse(${subcourse.id}) by User(${user?.userID})`); } diff --git a/common/notification/actions.ts b/common/notification/actions.ts index c3d42fa3e..005e5c587 100644 --- a/common/notification/actions.ts +++ b/common/notification/actions.ts @@ -210,10 +210,22 @@ const _notificationActions = { instructor_course_created: { description: 'Instructor / Course created (not yet published)', sampleContext: { - course: { - name: 'Hallo Welt', - description: 'Ein Kurs', - }, + courseName: 'Beispielkurs', + relation: 'subcourse/1', + }, + }, + instructor_course_submitted: { + description: 'Instructor / Course submitted for review', + sampleContext: { + courseName: 'Beispielkurs', + relation: 'subcourse/1', + }, + }, + instructor_course_approved: { + description: 'Instructor / Course approved', + sampleContext: { + courseName: 'Beispielkurs', + relation: 'subcourse/1', }, }, instructor_course_cancelled: { diff --git a/graphql/course/mutations.ts b/graphql/course/mutations.ts index f4943b55f..107e97e5b 100644 --- a/graphql/course/mutations.ts +++ b/graphql/course/mutations.ts @@ -1,4 +1,4 @@ -import { course_category_enum } from '@prisma/client'; +import { course_category_enum, user_achievement } from '@prisma/client'; import { UserInputError } from 'apollo-server-express'; import { getFile, removeFile } from '../files'; import { getLogger } from '../../common/logger/logger'; @@ -11,12 +11,14 @@ import { GraphQLContext } from '../context'; import * as GraphQLModel from '../generated/models'; import { getCourse, getStudent, getSubcoursesForCourse } from '../util'; import { putFile, DEFAULT_BUCKET } from '../../common/file-bucket'; +import * as Notification from '../../common/notification'; import { course_schooltype_enum as CourseSchooltype, course_subject_enum as CourseSubject, course_coursestate_enum as CourseState } from '../generated'; import { ForbiddenError } from '../error'; import { addCourseInstructor, allowCourse, denyCourse, subcourseOver } from '../../common/courses/states'; import { getCourseImageKey } from '../../common/courses/util'; import { createCourseTag } from '../../common/courses/tags'; +import { userForStudent } from '../../common/user'; @InputType() class PublicCourseCreateInput { @@ -90,6 +92,7 @@ export class MutateCourseResolver { @Arg('course') data: PublicCourseEditInput ): Promise { const course = await getCourse(courseId); + const user = context.user; await hasAccess(context, 'Course', course); if (course.courseState === 'allowed') { @@ -106,6 +109,30 @@ export class MutateCourseResolver { } const result = await prisma.course.update({ data, where: { id: courseId } }); logger.info(`Course (${result.id}) updated by Student (${context.user.studentId})`); + + const subcourse = await prisma.subcourse.findFirst({ + where: { courseId: courseId }, + }); + const usersSubcourseAchievements = await prisma.user_achievement.findMany({ + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, userId: user.userID }, + include: { template: true }, + }); + const subcourseAchievements = await Promise.all( + usersSubcourseAchievements.map(async (usersSubcourseAchievement) => { + return await prisma.user_achievement.findMany({ + where: { context: { path: ['relation'], equals: `subcourse/${subcourse.id}` }, templateId: usersSubcourseAchievement.template.id }, + }); + }) + ); + subcourseAchievements.flat().forEach(async (achievement) => { + const { context } = achievement; + context['courseName'] = result.name; + await prisma.user_achievement.update({ + where: { id: achievement.id }, + data: { context }, + }); + }); + return result; } @@ -193,6 +220,17 @@ export class MutateCourseResolver { await hasAccess(context, 'Course', course); await prisma.course.update({ data: { courseState: 'submitted' }, where: { id: courseId } }); logger.info(`Course (${courseId}) submitted by Student (${context.user.studentId})`); + + const subcourse = await prisma.subcourse.findFirst({ + where: { courseId: courseId }, + include: { subcourse_instructors_student: { select: { student: true } } }, + }); + subcourse.subcourse_instructors_student.forEach(async (instructor) => { + await Notification.actionTaken(userForStudent(instructor.student), 'instructor_course_submitted', { + courseName: course.name, + relation: `subcourse/${subcourse.id}`, + }); + }); return true; } diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts index 8b73e2df1..7351b4eca 100644 --- a/graphql/subcourse/mutations.ts +++ b/graphql/subcourse/mutations.ts @@ -17,6 +17,7 @@ import { getCourse, getPupil, getStudent, getSubcourse } from '../util'; import { chat_type } from '../generated'; import { markConversationAsReadOnly, removeParticipantFromCourseChat } from '../../common/chat/conversation'; import { sendPupilCoursePromotion } from '../../common/courses/notifications'; +import * as Notification from '../../common/notification'; const logger = getLogger('MutateCourseResolver'); @@ -99,6 +100,10 @@ export class MutateSubcourseResolver { const student = await getSessionStudent(context, studentId); await prisma.subcourse_instructors_student.create({ data: { subcourseId: result.id, studentId: student.id } }); + await Notification.actionTaken(userForStudent(student), 'instructor_course_created', { + courseName: course.name, + relation: `subcourse/${result.id}`, + }); logger.info(`Subcourse(${result.id}) was created for Course(${courseId}) and Student(${student.id})`); return result; } @@ -110,7 +115,6 @@ export class MutateSubcourseResolver { @Arg('subcourseId') subcourseId: number, @Arg('studentId') studentId: number ): Promise { - const { user } = context; const subcourse = await getSubcourse(subcourseId); await hasAccess(context, 'Subcourse', subcourse); @@ -127,7 +131,6 @@ export class MutateSubcourseResolver { @Arg('subcourseId') subcourseId: number, @Arg('studentId') studentId: number ): Promise { - const { user } = context; const subcourse = await getSubcourse(subcourseId); await hasAccess(context, 'Subcourse', subcourse); const instructorToBeRemoved = await getStudent(studentId); @@ -138,6 +141,14 @@ export class MutateSubcourseResolver { await removeParticipantFromCourseChat(instructorUser, subcourse.conversationId); } logger.info(`Student(${studentId}) was deleted from Subcourse(${subcourseId}) by User(${context.user.userID})`); + + await prisma.user_achievement.deleteMany({ + where: { + userId: `student/${studentId}`, + context: { path: ['relation'], equals: `subcourse/${subcourseId}` }, + }, + }); + return true; } diff --git a/seed-db.ts b/seed-db.ts index 979a4cfb6..92257f55c 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -1297,6 +1297,115 @@ void (async function setupDevDB() { }, }); + // STUDENT OFFER COURSE + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_create_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 1, + stepName: 'Kurs entwerfen', + type: achievement_type_enum.SEQUENTIAL, + subtitle: 'Vermittle Wissen', + description: 'Dieser Text muss noch geliefert werden.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs anlegen', + actionRedirectLink: '/create-course', + actionType: achievement_action_type_enum.Action, + condition: 'student_create_course_events > 0', + conditionDataAggregations: { + student_create_course_events: { + metric: 'student_create_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_submit_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 2, + stepName: 'Kurs zur Prüfung freigeben', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs freigeben', + actionRedirectLink: '/single-course/{{courseId}}', + actionType: achievement_action_type_enum.Action, + condition: 'student_submit_course_events > 0', + conditionDataAggregations: { + student_submit_course_events: { + metric: 'student_submit_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_approve_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 3, + stepName: 'Freigabe erhalten', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: 'Kurs absagen', + actionRedirectLink: '/single-course/{{courseId}}', + actionType: achievement_action_type_enum.Wait, + condition: 'student_approve_course_events > 0', + conditionDataAggregations: { + student_approve_course_events: { + metric: 'student_approve_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + await prisma.achievement_template.create({ + data: { + name: 'Kurs anbieten', + metrics: ['student_approve_course'], + templateFor: achievement_template_for_enum.Course, + group: 'student_offer_course', + groupOrder: 4, + stepName: 'Kurs erstellt!', + type: achievement_type_enum.SEQUENTIAL, + subtitle: '{{courseName}}', + description: + 'Dieser Text muss noch geliefert werden! Wie cool, dass du dich ehrenamtlich engagieren möchtest, indem du Schüler:innen durch Nachhilfeunterricht unterstützt. Um mit der Lernunterstützung zu starten sind mehrere Aktionen nötig. Schließe jetzt den nächsten Schritt ab und komme dem Ziel einer neuen Lernunterstüzung ein Stück näher.', + image: 'gamification/achievements/tmp/offer_course/offer_course.jpg', + achievedImage: '', + actionName: null, + actionRedirectLink: null, + actionType: null, + condition: 'student_approve_course_events > 0', + conditionDataAggregations: { + student_approve_course_events: { + metric: 'student_approve_course', + aggregator: 'count', + }, + }, + isActive: true, + }, + }); + // Add Instructors and Participants after adding Lectures, so that they are also added to the lectures: await addSubcourseInstructor(null, subcourse1, student1); await addSubcourseInstructor(null, subcourse1, student2);