Repository: corona-school/backend
Pull request #1202: Feat: Integrate students in the Hausaufgabenhilfe
Author: login: JeangelLF
Created: 2025-03-11T07:51:15Z | Merged: 2025-03-14T08:28:19Z

## Ticket

Partially resolves https://github.com/corona-school/project-user/issues/1428

## What was done?

- Added a new `subcourse_mentors_student` table where students can sign up to help on a course
- Added a new `SUBCOURSE_MENTOR` role
- Added mutations to allow students to join/leave courses that allow mentors _(only the Hausaufgabenhilfe for now)_

**Notes:**
- Mentors don't have edition rights over the course/subcourse
- The role isn't tied to having a specific screening
- Mentors can join/leave a course
- Mentors don't count as subcourse participants (only those who are learning do)
- Mentors don't auto-join the course chat / shouldn't receive notifications about the course. As this is mainly targeted at integrating the Hausaufgabenhilfe, the communication for that is for now handled a bit differently


Diff:
diff --git a/common/appointment/create.ts b/common/appointment/create.ts
index a73a96e7d..d25c41f57 100644
--- a/common/appointment/create.ts
+++ b/common/appointment/create.ts
@@ -131,6 +131,7 @@ export function canCreateGroupAppointment(subcourse: Subcourse, hasInstructors:
 
 export const createGroupAppointments = async (subcourseId: number, appointmentsToBeCreated: AppointmentCreateGroupInput[], organizer: Student) => {
     const participants = await prisma.subcourse_participants_pupil.findMany({ where: { subcourseId: subcourseId }, select: { pupil: true } });
+    const mentors = await prisma.subcourse_mentors_student.findMany({ where: { subcourseId: subcourseId }, select: { student: true } });
     const instructors = await prisma.subcourse_instructors_student.findMany({ where: { subcourseId: subcourseId }, select: { student: true } });
     const subcourse = await prisma.subcourse.findUnique({ where: { id: subcourseId }, include: { course: true } });
     const lastAppointment = await prisma.lecture.findFirst({ where: { subcourseId }, orderBy: { createdAt: 'desc' }, select: { override_meeting_link: true } });
@@ -162,7 +163,7 @@ export const createGroupAppointments = async (subcourseId: number, appointmentsT
                     subcourseId: appointmentToBeCreated.subcourseId,
                     appointmentType: lecture_appointmenttype_enum.group,
                     organizerIds: instructors.map((i) => userForStudent(i.student).userID),
-                    participantIds: participants.map((p) => userForPupil(p.pupil).userID),
+                    participantIds: [...participants.map((p) => userForPupil(p.pupil).userID), ...mentors.map((m) => userForStudent(m.student).userID)],
                     zoomMeetingId,
                     override_meeting_link: (appointmentToBeCreated.meetingLink ?? lastAppointment?.override_meeting_link) || null,
                 },
diff --git a/common/appointment/participants.ts b/common/appointment/participants.ts
index e5c4b1085..6c9261c66 100644
--- a/common/appointment/participants.ts
+++ b/common/appointment/participants.ts
@@ -44,12 +44,14 @@ export async function addGroupAppointmentsParticipant(subcourseId: number, userI
         await prisma.lecture.update({ where: { id: lecture.id }, data: { participantIds: { push: userId } } });
         logger.info(`User(${userId}) added as participant of Appointment(${lecture.id}) of Subcourse(${subcourseId})`);
 
-        await Notification.actionTakenAt(
-            new Date(lecture.start),
-            user,
-            'pupil_group_appointment_starts',
-            await getContextForGroupAppointmentReminder(lecture, subcourse, subcourse.course)
-        );
+        if (user.pupilId) {
+            await Notification.actionTakenAt(
+                new Date(lecture.start),
+                user,
+                'pupil_group_appointment_starts',
+                await getContextForGroupAppointmentReminder(lecture, subcourse, subcourse.course)
+            );
+        }
     }
 }
 
diff --git a/common/courses/filters.ts b/common/courses/filters.ts
index a628a480a..95dbb41cd 100644
--- a/common/courses/filters.ts
+++ b/common/courses/filters.ts
@@ -1,5 +1,11 @@
 import { Prisma, pupil as Pupil, student as Student } from '@prisma/client';
 
+export function isMentoredBy(student: Student) {
+    return {
+        subcourse_mentors_student: { some: { studentId: { equals: student.id } } },
+    };
+}
+
 export function instructedBy(student: Student) {
     return {
         subcourse_instructors_student: { some: { studentId: { equals: student.id } } },
diff --git a/common/courses/participants.ts b/common/courses/participants.ts
index 27b4b1fd8..788504b57 100644
--- a/common/courses/participants.ts
+++ b/common/courses/participants.ts
@@ -1,4 +1,4 @@
-import { course as Course, subcourse as Subcourse, pupil as Pupil } from '@prisma/client';
+import { course as Course, subcourse as Subcourse, pupil as Pupil, student as Student } from '@prisma/client';
 import { getLogger } from '../logger/logger';
 import { prisma } from '../prisma';
 import moment from 'moment';
@@ -67,6 +67,10 @@ export async function isParticipant(subcourse: Subcourse, pupil: Pupil) {
     );
 }
 
+export async function isMentor(subcourseId: number, studentId: number) {
+    return (await prisma.subcourse_mentors_student.count({ where: { subcourseId, studentId } })) > 0;
+}
+
 export async function isOnWaitingList(subcourse: Subcourse, pupil: Pupil) {
     return (
         (await prisma.waiting_list_enrollment.count({
@@ -198,6 +202,23 @@ export async function couldJoinSubcourse(subcourse: Subcourse, pupil: Pupil): Pr
     return { allowed: true };
 }
 
+export async function joinSubcourseAsMentor(subcourse: Subcourse, student: Student) {
+    const course = await prisma.course.findFirst({ where: { id: subcourse.courseId } });
+    if (course.category !== 'homework_help') {
+        throw new Error('Only homework_help courses allow mentors');
+    }
+    await prisma.subcourse_mentors_student.create({
+        data: {
+            studentId: student.id,
+            subcourseId: subcourse.id,
+        },
+    });
+
+    const user = userForStudent(student);
+    await logTransaction('mentorJoinedCourse', user, { subcourseID: subcourse.id });
+    await addGroupAppointmentsParticipant(subcourse.id, user.userID);
+}
+
 export async function joinSubcourse(subcourse: Subcourse, pupil: Pupil, strict: boolean, notifyIfFull = true): Promise<void> {
     const canJoin = await canJoinSubcourse(subcourse, pupil);
 
@@ -320,6 +341,22 @@ export async function leaveSubcourse(subcourse: Subcourse, pupil: Pupil) {
     }
 }
 
+export async function mentorLeaveSubcourse(subcourse: Subcourse, student: Student) {
+    const deletion = await prisma.subcourse_mentors_student.deleteMany({
+        where: {
+            subcourseId: subcourse.id,
+            studentId: student.id,
+        },
+    });
+    const studentUser = userForStudent(student);
+    await removeGroupAppointmentsParticipant(subcourse.id, studentUser.userID);
+    if (deletion.count === 0) {
+        throw new RedundantError(`Failed to leave Subcourse as the Student is not a mentor`);
+    }
+    logger.info(`Student(${student.id}) left Subcourse(${subcourse.id})`);
+    await logTransaction('mentorLeftCourse', studentUser, { subcourseID: subcourse.id });
+}
+
 export async function fillSubcourse(subcourse: Subcourse) {
     const participantCount = await prisma.subcourse_participants_pupil.count({ where: { subcourseId: subcourse.id } });
     const seatsLeft = subcourse.maxParticipants - participantCount;
diff --git a/common/transactionlog/log.ts b/common/transactionlog/log.ts
index 408dedd0c..b2f46c871 100644
--- a/common/transactionlog/log.ts
+++ b/common/transactionlog/log.ts
@@ -32,11 +32,12 @@ type LogData<Type extends LogType> = {
     cancelledCourse: { id: number };
     cancelledSubcourse: { id: number };
     createdCourseAttendanceLog: { courseAttendanceLog: CourseAttendanceLog };
-    contactMentor: { category: any; text: string; subject?: string };
     bbbMeeting: { bbbMeeting: BBBMeeting };
     contactExpert: { emailText: string; subject?: string };
     participantJoinedCourse: { subcourseID: number };
     participantLeftCourse: { subcourseID: number };
+    mentorJoinedCourse: { subcourseID: number };
+    mentorLeftCourse: { subcourseID: number };
     participantJoinedWaitingList: { courseID: number };
     participantLeftWaitingList: { courseID: number };
     userAccessedCourseWhileAuthenticated: { course: number };
diff --git a/common/user/index.ts b/common/user/index.ts
index f664b52c6..00d8365da 100644
--- a/common/user/index.ts
+++ b/common/user/index.ts
@@ -25,6 +25,13 @@ export type User = {
 };
 export const userSelection = { id: true, firstname: true, lastname: true, email: true, active: true, lastLogin: true };
 
+export function getPupilsIds(userIds: string[]) {
+    return userIds.filter((id) => {
+        const [type] = getUserTypeAndIdForUserId(id);
+        return type === 'pupil';
+    });
+}
+
 export function getUserTypeAndIdForUserId(userId: string): [type: UserTypes, id: number] {
     const validTypes = ['student', 'pupil', 'screener', 'admin'];
     const [type, id] = userId.split('/');
@@ -138,7 +145,7 @@ export async function getTotalSupportedHours(userId: string): Promise<number> {
     }, 0);
 
     const totalPupilDuration = pupilLectures.reduce((acc, lecture) => {
-        const actualParticipants = lecture.participantIds.filter((id) => pupils.includes(id) && !lecture.declinedBy.includes(id));
+        const actualParticipants = getPupilsIds(lecture.participantIds).filter((id) => pupils.includes(id) && !lecture.declinedBy.includes(id));
         const participantCount = actualParticipants.length;
         return acc + lecture.duration * participantCount;
     }, 0);
diff --git a/common/user/roles.ts b/common/user/roles.ts
index 1cb96e22d..18a584dd1 100644
--- a/common/user/roles.ts
+++ b/common/user/roles.ts
@@ -42,6 +42,8 @@ export enum Role {
     STATE_PUPIL = 'STATE_PUPIL',
     /* User is a pupil and participant of a specific subcourse */
     SUBCOURSE_PARTICIPANT = 'SUBCOURSE_PARTICIPANT',
+    /* User is a student and mentor of a specific subcourse */
+    SUBCOURSE_MENTOR = 'SUBCOURSE_MENTOR',
     /* User is a pupil and participant of a specific appointment */
     APPOINTMENT_PARTICIPANT = 'APPOINTMENT_PARTICIPANT',
     /**
diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts
index 98df83b56..9979f3774 100644
--- a/graphql/authorizations.ts
+++ b/graphql/authorizations.ts
@@ -19,8 +19,8 @@ import assert from 'assert';
 import { getLogger } from '../common/logger/logger';
 import { isOwnedBy, ResolverModel, ResolverModelNames } from './ownership';
 import { AuthenticationError, ForbiddenError } from './error';
-import { isParticipant } from '../common/courses/participants';
-import { getPupil } from './util';
+import { isMentor, isParticipant } from '../common/courses/participants';
+import { getPupil, getStudent } from './util';
 import { Role } from '../common/user/roles';
 import { isDev, isTest } from '../common/util/environment';
 import { isAppointmentParticipant } from '../common/appointment/participants';
@@ -221,11 +221,20 @@ const entityRoles: EntityRole[] = [
                 const pupil = await getPupil(context.user.pupilId);
                 return await isParticipant(root, pupil);
             }
-
             return false;
         },
     },
-
+    {
+        role: Role.SUBCOURSE_MENTOR,
+        async hasRole(context, modelName, root) {
+            assert(modelName === 'Subcourse', 'Type must be a Subcourse to determine subcourse participant role');
+            if (context.user.studentId) {
+                const student = await getStudent(context.user.studentId);
+                return await isMentor(root.id, student.id);
+            }
+            return false;
+        },
+    },
     {
         role: Role.APPOINTMENT_PARTICIPANT,
         async hasRole(context, modelName, root) {
@@ -246,7 +255,7 @@ const onlyOwner = [Authorized(Role.OWNER)];
 const nobody = [Authorized(Role.NOBODY)];
 const everyone = [Authorized(Role.UNAUTHENTICATED)];
 const participantOrOwnerOrAdmin = [Authorized(Role.ADMIN, Role.APPOINTMENT_PARTICIPANT, Role.OWNER)];
-const subcourseParticipantOrOwner = [Authorized(Role.SUBCOURSE_PARTICIPANT, Role.OWNER)];
+const subcourseParticipantOrOwner = [Authorized(Role.SUBCOURSE_PARTICIPANT, Role.SUBCOURSE_MENTOR, Role.OWNER)];
 
 /* Utility to ensure that field authorizations are present except for the public fields listed */
 const withPublicFields = <Entity = 'never', PublicFields extends keyof Entity = never>(otherFields: {
@@ -313,6 +322,7 @@ export const authorizationEnhanceMap: Required<ResolversEnhanceMap> = {
     },
     Subcourse_promotion: allAdmin,
     Subcourse_instructors_student: allAdmin,
+    Subcourse_mentors_student: allAdmin,
     Subcourse_participants_pupil: allAdmin,
     Concrete_notification: allAdmin,
     Course_guest: allAdmin,
@@ -456,6 +466,7 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = {
             participation_certificate: nobody,
             instant_certificate: nobody,
             subcourse_instructors_student: nobody,
+            subcourse_mentors_student: nobody,
             course: nobody,
             course_guest: nobody,
             course_instructors_student: nobody,
@@ -527,6 +538,7 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = {
             course_participation_certificate: nobody,
             lecture: nobody,
             subcourse_instructors_student: nobody,
+            subcourse_mentors_student: nobody,
             subcourse_participants_pupil: nobody,
             _count: nobody,
             alreadyPromoted: adminOrOwner,
diff --git a/graphql/student/fields.ts b/graphql/student/fields.ts
index 17e625dd5..e98ffde96 100644
--- a/graphql/student/fields.ts
+++ b/graphql/student/fields.ts
@@ -23,7 +23,7 @@ import { strictUserSearch, userSearch } from '../../common/user/search';
 import { Instructor } from '../types/instructor';
 import { GraphQLContext } from '../context';
 import { predictedHookActionDate } from '../../common/notification';
-import { excludeCancelledSubcourses, excludePastSubcourses, instructedBy } from '../../common/courses/filters';
+import { excludeCancelledSubcourses, excludePastSubcourses, instructedBy, isMentoredBy } from '../../common/courses/filters';
 import { Prisma } from '@prisma/client';
 import assert from 'assert';
 import { isSessionStudent } from '../authentication';
@@ -155,6 +155,33 @@ export class ExtendFieldsStudentResolver {
         });
     }
 
+    @LimitEstimated(10)
+    @Authorized(Role.ADMIN, Role.OWNER, Role.SUBCOURSE_MENTOR)
+    @FieldResolver((type) => [Subcourse])
+    @ImpliesRoleOnResult(Role.SUBCOURSE_MENTOR, Role.OWNER)
+    async subcoursesMentoring(
+        @Root() student: Required<Student>,
+        @Arg('excludePast', { nullable: true }) excludePast?: boolean,
+        @Arg('excludeCancelled', { nullable: true }) excludeCancelled?: boolean,
+        @Arg('search', { nullable: true }) search?: string
+    ) {
+        const filters: Prisma.subcourseWhereInput[] = [isMentoredBy(student)];
+
+        if (excludePast) {
+            filters.push(excludePastSubcourses());
+        }
+
+        if (excludeCancelled) {
+            filters.push(excludeCancelledSubcourses());
+        }
+
+        if (search) {
+            filters.push(await subcourseSearch(search));
+        }
+
+        return await prisma.subcourse.findMany({ where: { AND: filters } });
+    }
+
     @FieldResolver((type) => [Subcourse])
     @Authorized(Role.ADMIN, Role.OWNER, Role.SCREENER)
     @LimitEstimated(10)
diff --git a/graphql/subcourse/fields.ts b/graphql/subcourse/fields.ts
index 9c558352b..4666cfbcf 100644
--- a/graphql/subcourse/fields.ts
+++ b/graphql/subcourse/fields.ts
@@ -1,7 +1,7 @@
 import { course_coursestate_enum as CourseState, Prisma, subcourse } from '@prisma/client';
 import { canCancel, canDeleteSubcourse, canEditSubcourse, canPublish } from '../../common/courses/states';
 import { Arg, Authorized, Ctx, Field, FieldResolver, Int, ObjectType, Query, Resolver, Root } from 'type-graphql';
-import { canJoinSubcourse, couldJoinSubcourse, isParticipant } from '../../common/courses/participants';
+import { canJoinSubcourse, couldJoinSubcourse, isParticipant, isMentor as isSubcourseMentor } from '../../common/courses/participants';
 import { prisma } from '../../common/prisma';
 import { getSessionPupil, getSessionStudent, isElevated, isSessionPupil, isSessionStudent } from '../authentication';
 import { Role } from '../authorizations';
@@ -467,6 +467,17 @@ export class ExtendedFieldsSubcourseResolver {
         return (await prisma.subcourse_instructors_student.count({ where: { subcourseId: subcourse.id, studentId: student.id } })) > 0;
     }
 
+    @FieldResolver((returns) => Boolean)
+    @Authorized(Role.UNAUTHENTICATED)
+    async isMentor(@Ctx() context: GraphQLContext, @Root() subcourse: Subcourse, @Arg('studentId', { nullable: true }) studentId: number) {
+        if (!isElevated(context) && !isSessionStudent(context)) {
+            return false;
+        }
+
+        const student = await getSessionStudent(context, studentId);
+        return isSubcourseMentor(subcourse.id, student.id);
+    }
+
     @FieldResolver((returns) => Boolean)
     @Authorized(Role.UNAUTHENTICATED)
     async isOnWaitingList(@Ctx() context: GraphQLContext, @Root() subcourse: Subcourse, @Arg('pupilId', { nullable: true }) pupilId: number) {
diff --git a/graphql/subcourse/mutations.ts b/graphql/subcourse/mutations.ts
index d3fa9b43c..990e17fb8 100644
--- a/graphql/subcourse/mutations.ts
+++ b/graphql/subcourse/mutations.ts
@@ -2,7 +2,15 @@ import * as TypeGraphQL from 'type-graphql';
 import { Arg, Authorized, Ctx, InputType, Int, Mutation, Resolver } from 'type-graphql';
 import { removeGroupAppointmentsOrganizer, removeGroupAppointmentsParticipant } from '../../common/appointment/participants';
 import { contactInstructors, contactParticipants } from '../../common/courses/contact';
-import { fillSubcourse, joinSubcourse, joinSubcourseWaitinglist, leaveSubcourse, leaveSubcourseWaitinglist } from '../../common/courses/participants';
+import {
+    fillSubcourse,
+    joinSubcourse,
+    joinSubcourseAsMentor,
+    joinSubcourseWaitinglist,
+    leaveSubcourse,
+    leaveSubcourseWaitinglist,
+    mentorLeaveSubcourse,
+} from '../../common/courses/participants';
 import { addSubcourseInstructor, cancelSubcourse, deleteSubcourse, editSubcourse, publishSubcourse } from '../../common/courses/states';
 import { getLogger } from '../../common/logger/logger';
 import { prisma } from '../../common/prisma';
@@ -240,6 +248,42 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR, Role.OWNER)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        const isOwner = studentId && (await prisma.subcourse_instructors_student.count({ where: { subcourseId, studentId: context.user.studentId } })) > 0;
+        const student = await (isOwner ? getStudent(studentId) : getSessionStudent(context, studentId));
+        const studentUser = userForStudent(student);
+        await mentorLeaveSubcourse(subcourse, student);
+        await removeGroupAppointmentsParticipant(subcourse.id, studentUser.userID);
+        if (subcourse.conversationId) {
+            await removeParticipantFromCourseChat(studentUser, subcourse.conversationId);
+        }
+        logger.info(`Student(${studentId}) left Subcourse(${subcourseId})`);
+        return true;
+    }
+
     @Mutation((returns) => Boolean)
     @Authorized(Role.ADMIN, Role.COURSE_SCREENER)
     async subcourseJoinManual(
diff --git a/linter/graphql-undeferred-impossible.js b/linter/graphql-undeferred-impossible.js
index 602d7a707..c359060b7 100644
--- a/linter/graphql-undeferred-impossible.js
+++ b/linter/graphql-undeferred-impossible.js
@@ -33,7 +33,7 @@ module.exports = {
                     return;
                 }
 
-                const needsToBeDeferred = (undeferredAuth ?? deferredAuth).expression.arguments.some((it) => ['OWNER', 'SUBCOURSE_PARTICIPANT'].includes(it.property.name));
+                const needsToBeDeferred = (undeferredAuth ?? deferredAuth).expression.arguments.some((it) => ['OWNER', 'SUBCOURSE_PARTICIPANT', 'SUBCOURSE_MENTOR'].includes(it.property.name));
 
                 if (undeferredAuth && needsToBeDeferred) {
                     context.report({
diff --git a/prisma/migrations/20250312104151_add_subcourse_mentors_student_table/migration.sql b/prisma/migrations/20250312104151_add_subcourse_mentors_student_table/migration.sql
new file mode 100644
index 000000000..56c556dc1
--- /dev/null
+++ b/prisma/migrations/20250312104151_add_subcourse_mentors_student_table/migration.sql
@@ -0,0 +1,30 @@
+/*
+  Warnings:
+
+  - The values [contactMentor] on the enum `log_logtype_enum` will be removed. If these variants are still used in the database, this will fail.
+
+*/
+-- AlterEnum
+BEGIN;
+CREATE TYPE "log_logtype_enum_new" AS ENUM ('misc', 'verificationRequets', 'verified', 'matchDissolve', 'fetchedFromWix', 'deActivate', 'updatePersonal', 'updateSubjects', 'accessedByScreener', 'updatedByScreener', 'updateStudentDescription', 'createdCourse', 'certificateRequest', 'cocCancel', 'cancelledCourse', 'cancelledSubcourse', 'createdCourseAttendanceLog', 'bbbMeeting', 'participantJoinedCourse', 'participantLeftCourse', 'mentorJoinedCourse', 'mentorLeftCourse', 'participantJoinedWaitingList', 'participantLeftWaitingList', 'userAccessedCourseWhileAuthenticated', 'instructorIssuedCertificate', 'pupilInterestConfirmationRequestSent', 'pupilInterestConfirmationRequestReminderSent', 'pupilInterestConfirmationRequestStatusChange', 'skippedCoC');
+ALTER TABLE "log" ALTER COLUMN "logtype" DROP DEFAULT;
+ALTER TABLE "log" ALTER COLUMN "logtype" TYPE "log_logtype_enum_new" USING ("logtype"::text::"log_logtype_enum_new");
+ALTER TYPE "log_logtype_enum" RENAME TO "log_logtype_enum_old";
+ALTER TYPE "log_logtype_enum_new" RENAME TO "log_logtype_enum";
+DROP TYPE "log_logtype_enum_old";
+ALTER TABLE "log" ALTER COLUMN "logtype" SET DEFAULT 'misc';
+COMMIT;
+
+-- CreateTable
+CREATE TABLE "subcourse_mentors_student" (
+    "subcourseId" INTEGER NOT NULL,
+    "studentId" INTEGER NOT NULL,
+
+    CONSTRAINT "subcourse_mentors_student_pkey" PRIMARY KEY ("subcourseId","studentId")
+);
+
+-- AddForeignKey
+ALTER TABLE "subcourse_mentors_student" ADD CONSTRAINT "subcourse_mentors_student_subcourseId_fkey" FOREIGN KEY ("subcourseId") REFERENCES "subcourse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "subcourse_mentors_student" ADD CONSTRAINT "subcourse_mentors_student_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "student"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 47bc38ec9..1402ef719 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -681,29 +681,29 @@ model school {
 // Historically there was a dedicated Screening Tool were screeners could log in.
 // Nowadays Screenings are done via Retool, with the Default Screener
 model screener {
-  id                           Int                          @id(map: "PK_3a023b02ed01df4a6956af1ea94") @default(autoincrement())
-  createdAt                    DateTime                     @default(now()) @db.Timestamp(6)
-  updatedAt                    DateTime                     @default(now()) @updatedAt @db.Timestamp(6)
-  firstname                    String?                      @db.VarChar
-  lastname                     String?                      @db.VarChar
-  active                       Boolean                      @default(true)
-  email                        String                       @unique(map: "IDX_29a6207bc70a2b9e6731d66bcf") @db.VarChar
-  verifiedAt                   DateTime?                    @db.Timestamp(6)
-  isRedacted                   Boolean                      @default(false)
-  lastTimeCheckedNotifications DateTime?                    @default(dbgenerated("'1970-01-01 00:00:00'::timestamp without time zone")) @db.Timestamp(6)
-  notificationPreferences      Json?                        @db.Json
-  lastLogin                    DateTime?                    @default(now()) @db.Timestamp(6)
+  id                           Int                    @id(map: "PK_3a023b02ed01df4a6956af1ea94") @default(autoincrement())
+  createdAt                    DateTime               @default(now()) @db.Timestamp(6)
+  updatedAt                    DateTime               @default(now()) @updatedAt @db.Timestamp(6)
+  firstname                    String?                @db.VarChar
+  lastname                     String?                @db.VarChar
+  active                       Boolean                @default(true)
+  email                        String                 @unique(map: "IDX_29a6207bc70a2b9e6731d66bcf") @db.VarChar
+  verifiedAt                   DateTime?              @db.Timestamp(6)
+  isRedacted                   Boolean                @default(false)
+  lastTimeCheckedNotifications DateTime?              @default(dbgenerated("'1970-01-01 00:00:00'::timestamp without time zone")) @db.Timestamp(6)
+  notificationPreferences      Json?                  @db.Json
+  lastLogin                    DateTime?              @default(now()) @db.Timestamp(6)
   // DEPRECATED, Use Secret instead
-  password                     String                       @db.VarChar
-  verified                     Boolean?                     @default(false)
+  password                     String                 @db.VarChar
+  verified                     Boolean?               @default(false)
   // DEPRECATED:
-  oldNumberID                  Int?                         @unique(map: "UQ_96dc11de485d62615e78a875293")
+  oldNumberID                  Int?                   @unique(map: "UQ_96dc11de485d62615e78a875293")
   instructor_screening         instructor_screening[]
   screenings                   screening[]
-  is_trusted                   Boolean                      @default(false)
-  is_student_screener          Boolean                      @default(false)
-  is_pupil_screener            Boolean                      @default(false)
-  is_course_screener           Boolean                      @default(false)
+  is_trusted                   Boolean                @default(false)
+  is_student_screener          Boolean                @default(false)
+  is_pupil_screener            Boolean                @default(false)
+  is_course_screener           Boolean                @default(false)
 }
 
 // A Screening for Students that want to tutor pupils
@@ -774,28 +774,28 @@ model student {
   sentInstructorScreeningReminderCount      Int                      @default(0)
   lastSentInstructorScreeningInvitationDate DateTime?                @db.Timestamp(6)
 
-  lastUpdatedSettingsViaBlocker        DateTime?                              @db.Timestamp(6)
+  lastUpdatedSettingsViaBlocker    DateTime?                          @db.Timestamp(6)
   // The registration form used to register the user, mainly used to find Lern-Fair Plus students
-  registrationSource                   student_registrationsource_enum        @default(normal)
-  aboutMe                              String                                 @default("") @db.VarChar
+  registrationSource               student_registrationsource_enum    @default(normal)
+  aboutMe                          String                             @default("") @db.VarChar
   // Currently only Students can create Appointments with a Zoom Meeting, for that they need a Zoom Account:
-  zoomUserId                           String?                                @db.VarChar
-  certificate_of_conduct               certificate_of_conduct?
-  course                               course[]
-  course_guest                         course_guest[]
-  course_instructors_student           course_instructors_student[]
-  course_participation_certificate     course_participation_certificate[]
-  instructor_screening                 instructor_screening?
-  jufo_verification_transmission       jufo_verification_transmission?
-  lecture                              lecture[]
-  match                                match[]
-  participation_certificate            participation_certificate[]
-  instant_certificate                  instant_certificate[]
-  remission_request                    remission_request?
-  screening                            screening?
-  subcourse_instructors_student        subcourse_instructors_student[]
+  zoomUserId                       String?                            @db.VarChar
+  certificate_of_conduct           certificate_of_conduct?
+  course                           course[]
+  course_guest                     course_guest[]
+  course_instructors_student       course_instructors_student[]
+  course_participation_certificate course_participation_certificate[]
+  instructor_screening             instructor_screening?
+  jufo_verification_transmission   jufo_verification_transmission?
+  lecture                          lecture[]
+  match                            match[]
+  participation_certificate        participation_certificate[]
+  instant_certificate              instant_certificate[]
+  remission_request                remission_request?
+  screening                        screening?
+  subcourse_instructors_student    subcourse_instructors_student[]
   // Should stay null for users who where registered before the onboarding got added
-  hasDoneEthicsOnboarding              Boolean?
+  hasDoneEthicsOnboarding          Boolean?
 
   cooperationID           Int?
   cooperation             cooperation? @relation(fields: [cooperationID], references: [id])
@@ -809,7 +809,8 @@ model student {
   gender                  gender_enum?
 
   // The referring user's type and ID, e.g., "pupil/5", "student/3"
-  referredById String? @db.VarChar
+  referredById              String?                     @db.VarChar
+  subcourse_mentors_student subcourse_mentors_student[]
 }
 
 // A concrete course with participants, each course might have multiple subcourses with different instructors
@@ -843,6 +844,7 @@ model subcourse {
   subcourse_participants_pupil     subcourse_participants_pupil[]
   waiting_list_enrollment          waiting_list_enrollment[]
   subcourse_promotions             subcourse_promotion[]
+  subcourse_mentors_student        subcourse_mentors_student[]
 }
 
 model subcourse_promotion {
@@ -853,6 +855,18 @@ model subcourse_promotion {
   subcourseId Int
 }
 
+// These students only support pupils in the course. They don't own the course/subcourse (so, no edition rights)
+// They can join/leave courses that allow mentors (For now only used the Hausaufgabenhilfe)
+// They don't count as course participants (e.g for maxParticipants, referrals, etc...)
+model subcourse_mentors_student {
+  subcourseId Int
+  studentId   Int
+  subcourse   subcourse @relation(fields: [subcourseId], references: [id], onDelete: Cascade)
+  student     student   @relation(fields: [studentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
+
+  @@id([subcourseId, studentId])
+}
+
 // This is redundant with the Appointment.organizerIds of the appointments attached to the Subcourse and should generally be in sync
 model subcourse_instructors_student {
   subcourseId Int
@@ -1043,10 +1057,11 @@ enum log_logtype_enum {
   cancelledCourse
   cancelledSubcourse
   createdCourseAttendanceLog
-  contactMentor
   bbbMeeting
   participantJoinedCourse
   participantLeftCourse
+  mentorJoinedCourse
+  mentorLeftCourse
   participantJoinedWaitingList
   participantLeftWaitingList
   userAccessedCourseWhileAuthenticated
@@ -1385,6 +1400,7 @@ enum dissolve_reason {
   other // "Sonstiges"
   unknown // legacy data which we can't map anymore
 }
+
 enum gender_enum {
   male
   female
diff --git a/seed-db.ts b/seed-db.ts
index a2abb75e0..aa339466e 100644
--- a/seed-db.ts
+++ b/seed-db.ts
@@ -716,12 +716,11 @@ void (async function setupDevDB() {
         outline: 'Hausaufgabenhilfe',
         description:
             'Wenn du Hilfe bei deinen Schulaufgaben brauchst und dir Zuhause niemand helfen kann, dann helfen wir dir. Montag bis Donnerstag von 17-18 Uhr sind wir auf Zoom. \n\nBitte bringe deine Aufgaben mit, die du am Bildschirm zeigen kannst. \nZum Beispiel: \n- Deine Hausaufgaben \n- Übungsaufgaben für eine Klassenarbeit \n- Aufgaben, die du im Unterricht nicht verstanden hast',
-        category: CourseCategory.focus,
+        category: CourseCategory.homework_help,
         state: CourseState.allowed,
-        course_tags_course_tag: { create: { courseTagId: keepAtIt.id } },
         maxParticipants: 1000,
-        instructors: [student1, student2, student3, student4, student5],
-        participants: [pupil1, pupil2, pupil3, pupil4, pupil5, pupil6, pupil7, pupil8, pupil9, pupil10],
+        instructors: [student1, student2, student3, student5],
+        participants: [pupil1, pupil2, pupil3, pupil5, pupil6, pupil7, pupil8, pupil9, pupil10],
         lectures: { amount: 15, intervalInDays: 7, startOffsetInDays: -14 },
     });
 


Reviews:
user: login: Jonasdoubleyou
body: Looks good to me! :) 
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-11T17:56:08Z
user: login: JeangelLF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:15:47Z
user: login: JeangelLF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:16:02Z
user: login: JeangelLF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:16:14Z
user: login: Jonasdoubleyou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T14:26:12Z
user: login: JeangelLF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:14:51Z
user: login: JeangelLF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:01:31Z
user: login: Jonasdoubleyou
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T17:41:20Z

Review comments:
diff_hunk: @@ -198,6 +202,27 @@ export async function couldJoinSubcourse(subcourse: Subcourse, pupil: Pupil): Pr
     return { allowed: true };
 }
 
+export async function joinSubcourseAsMentor(subcourse: Subcourse, student: Student) {
+    const course = await prisma.course.findFirst({ where: { id: subcourse.courseId } });
+    if (course.category !== 'homework_help') {
+        throw new Error('Only homework_help courses allow mentors');
+    }
+    const insertion = await prisma.subcourse_mentors_student.create({
+        data: {
+            studentId: student.id,
+            subcourseId: subcourse.id,
+        },
+    });
+
+    if (insertion === null) {
path: common/courses/participants.ts
user: login: Jonasdoubleyou
body: create(...) never returns null, it throws ...
created_at: 2025-03-11T17:44:17Z
author_association: MEMBER
diff_hunk: @@ -240,6 +248,51 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
path: graphql/subcourse/mutations.ts
user: login: Jonasdoubleyou
body: So all students can join any course? Shouldn't they be added by someone?
created_at: 2025-03-11T17:47:43Z
author_association: MEMBER
diff_hunk: @@ -240,6 +248,51 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        if (context.user.studentId && studentId && context.user.studentId !== studentId) {
path: graphql/subcourse/mutations.ts
user: login: Jonasdoubleyou
body: This is equivalent to `getSessionStudent(...)` - it is missing the "Owner" handling.
To also allow Owners to remove them, role `Role.OWNER` is missing
created_at: 2025-03-11T17:51:44Z
author_association: MEMBER
diff_hunk: @@ -240,6 +248,51 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
path: graphql/subcourse/mutations.ts
user: login: Jonasdoubleyou
body: Ah, we restrict it only to homework courses, okay.
created_at: 2025-03-11T17:52:51Z
author_association: MEMBER
diff_hunk: @@ -44,12 +44,14 @@ export async function addGroupAppointmentsParticipant(subcourseId: number, userI
         await prisma.lecture.update({ where: { id: lecture.id }, data: { participantIds: { push: userId } } });
         logger.info(`User(${userId}) added as participant of Appointment(${lecture.id}) of Subcourse(${subcourseId})`);
 
-        await Notification.actionTakenAt(
-            new Date(lecture.start),
-            user,
-            'pupil_group_appointment_starts',
-            await getContextForGroupAppointmentReminder(lecture, subcourse, subcourse.course)
-        );
+        if (user.pupilId) {
path: common/appointment/participants.ts
user: login: Jonasdoubleyou
body: Also the following places need adaption:
- https://github.com/corona-school/backend/blob/071ba809dd352fb12f14ad050921e89c7f0436e3/common/appointment/create.ts#L185
- https://github.com/corona-school/backend/blob/071ba809dd352fb12f14ad050921e89c7f0436e3/common/appointment/update.ts#L57
created_at: 2025-03-11T17:55:35Z
author_association: MEMBER
diff_hunk: @@ -198,6 +202,27 @@ export async function couldJoinSubcourse(subcourse: Subcourse, pupil: Pupil): Pr
     return { allowed: true };
 }
 
+export async function joinSubcourseAsMentor(subcourse: Subcourse, student: Student) {
+    const course = await prisma.course.findFirst({ where: { id: subcourse.courseId } });
+    if (course.category !== 'homework_help') {
+        throw new Error('Only homework_help courses allow mentors');
+    }
+    const insertion = await prisma.subcourse_mentors_student.create({
+        data: {
+            studentId: student.id,
+            subcourseId: subcourse.id,
+        },
+    });
+
+    if (insertion === null) {
path: common/courses/participants.ts
user: login: JeangelLF
body: Oh sure, I removed the unneeded/false validation.
created_at: 2025-03-12T10:15:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,6 +248,51 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        if (context.user.studentId && studentId && context.user.studentId !== studentId) {
path: graphql/subcourse/mutations.ts
user: login: JeangelLF
body: Thanks for the hint! I adjusted it with `getSessionStudent` and also included the Owner scenario.
created_at: 2025-03-12T10:16:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,12 +44,14 @@ export async function addGroupAppointmentsParticipant(subcourseId: number, userI
         await prisma.lecture.update({ where: { id: lecture.id }, data: { participantIds: { push: userId } } });
         logger.info(`User(${userId}) added as participant of Appointment(${lecture.id}) of Subcourse(${subcourseId})`);
 
-        await Notification.actionTakenAt(
-            new Date(lecture.start),
-            user,
-            'pupil_group_appointment_starts',
-            await getContextForGroupAppointmentReminder(lecture, subcourse, subcourse.course)
-        );
+        if (user.pupilId) {
path: common/appointment/participants.ts
user: login: JeangelLF
body: I just checked these two places and they seem fine (I think) because we're fetching pupils specifically from `subcourse_participants_pupil`.
created_at: 2025-03-12T10:16:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,6 +248,42 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR, Role.OWNER)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        const isOwner = context.user.roles.includes(Role.OWNER) && !!studentId;
path: graphql/subcourse/mutations.ts
user: login: Jonasdoubleyou
body: Does this work? Shouldn't context.user.roles only include the fixed roles, not the ones computed per entity?
created_at: 2025-03-12T14:26:08Z
author_association: MEMBER
diff_hunk: @@ -240,6 +248,42 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR, Role.OWNER)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        const isOwner = context.user.roles.includes(Role.OWNER) && !!studentId;
path: graphql/subcourse/mutations.ts
user: login: JeangelLF
body: 🤦‍♂️ I might have tested the wrong scenario with the owner...
This is then interesting, I think we don't have this same situation elsewhere, do we? I see we usually create separate mutations in some somewhat similar cases. For example, a pupil joining a course vs an admin/owner adding a pupil to a course _(even if that works slightly differently)_
created_at: 2025-03-12T15:14:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,6 +248,42 @@ export class MutateSubcourseResolver {
         return true;
     }
 
+    @Mutation((returns) => Boolean)
+    @Authorized(Role.ADMIN, Role.STUDENT, Role.COURSE_SCREENER)
+    async subcourseJoinAsMentor(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const student = await getSessionStudent(context, studentId);
+        const subcourse = await getSubcourse(subcourseId);
+        await joinSubcourseAsMentor(subcourse, student);
+        logger.info(`Student(${studentId}) joined Subcourse(${subcourseId}) as mentor`);
+        return true;
+    }
+
+    @Mutation((returns) => Boolean)
+    @AuthorizedDeferred(Role.ADMIN, Role.SUBCOURSE_MENTOR, Role.OWNER)
+    async subcourseMentorLeave(
+        @Ctx() context: GraphQLContext,
+        @Arg('subcourseId') subcourseId: number,
+        @Arg('studentId', { nullable: true }) studentId?: number
+    ): Promise<boolean> {
+        const subcourse = await getSubcourse(subcourseId);
+        await hasAccess(context, 'Subcourse', subcourse);
+        // Make sure that only Admins/Owners can remove other mentors from the course
+        const isOwner = context.user.roles.includes(Role.OWNER) && !!studentId;
path: graphql/subcourse/mutations.ts
user: login: JeangelLF
body: Alright, I adjusted it to check if the context user is an instructor of the subcourse
created_at: 2025-03-13T15:01:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: JeangelLF
created_at: 2025-03-11T08:08:55Z
body: Hey @Jonasdoubleyou, I'd like to get your opinion on the approach I took to integrate the volunteers in the Hausaufgabenhilfe :) This is an initial phase. If this is fine some other PRs will follow with some improvements.

So, for some context. Currently, the Hausaufgabenhilfe is a Fokus-Kurs on prod, where pupils can sign up to attend the appointments as usual. The problem is that students aren't integrated at all. They aren't instructors because we don't want them to be able to edit the course/subcourse and receive notifications about the meetings or changes in the course, and they also wouldn't have the flexibility to join/leave the course as they want. Instead, the Volunteers join directly to the Zoom meeting using a fixed link, which is not ideal...
author_association: CONTRIBUTOR
user: login: Jonasdoubleyou
created_at: 2025-03-11T17:57:52Z
body: I mean we explicitly designed meetings in the way that participants can be students (or screeners, or ...), so apart from a few minor bugs this "should just work" ... Let's see :) 
author_association: MEMBER