Repository: uzh-bf/klicker-uzh
Pull request #4632: fix(packages/graphql): refine logic for access request removal to be limited to cases where users do not retain ADMIN object access through other paths
Author: login: sjschlapbach
Labels: name: size:XL
description: This PR changes 500-999 lines, ignoring generated files.
Created: 2025-04-21T21:46:44Z | Merged: 2025-04-22T07:29:22Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
  - Improved handling of access requests so they are only removed for users who fully lose ADMIN or OWNER permissions, ensuring access is retained if users still have such rights via other permissions.

- **Tests**
  - Added comprehensive tests to verify correct behavior of access requests when ADMIN permissions are reduced or revoked, especially in scenarios involving both individual and group permissions.
  - Expanded test data and setup to include an additional mock user for more robust coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/packages/graphql/src/services/sharing.ts b/packages/graphql/src/services/sharing.ts
index 44bb6dced0..a9d3f76b96 100644
--- a/packages/graphql/src/services/sharing.ts
+++ b/packages/graphql/src/services/sharing.ts
@@ -2103,103 +2103,61 @@ export async function changeObjectPermissionLevel(
       },
     })
 
-    // if the permission exists, trigger recomputation of derived permissions
-    if (updatedPermission) {
-      const affectedUserIds = updatedPermission.userId
-        ? [updatedPermission.userId]
-        : userGroup
-          ? userGroup.members.flatMap((member) =>
-              member.id ? [member.id] : []
-            )
-          : []
-
-      for (const affectedUserId of affectedUserIds) {
-        if (typeof catalogCollectionId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { catalogCollectionId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof answerCollectionId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { answerCollectionId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof elementId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { elementId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof courseId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { courseId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof liveQuizId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { liveQuizId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof practiceQuizId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { practiceQuizId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof microLearningId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { microLearningId, userId: affectedUserId },
-            prisma
-          )
-        } else if (typeof groupActivityId !== 'undefined') {
-          await recomputeDerivedPermissions(
-            { groupActivityId, userId: affectedUserId },
-            prisma
-          )
-        }
-      }
+    // if the permission does not exist, return early
+    if (!updatedPermission) {
+      return false
     }
 
-    if (updatedPermission.userId) {
-      // if ADMIN permissions were granted, make sure the user also gets access request instances assigned
-      if (
-        previousPermission.permissionLevel !== DB.PermissionLevel.ADMIN &&
-        permissionLevel === DB.PermissionLevel.ADMIN
-      ) {
-        await createAccessRequestInstancesNewAdmin(
-          {
-            newAdminId: updatedPermission.userId,
-            existingAdminOwnerId: ctx.user.sub,
-            catalogCollectionId,
-            answerCollectionId,
-            elementId,
-            courseId,
-            liveQuizId,
-            practiceQuizId,
-            microLearningId,
-            groupActivityId,
-          },
+    // if the permission exists, trigger recomputation of derived permissions, potentially update the access requests and log the change
+    const affectedUserIds = updatedPermission.userId
+      ? [updatedPermission.userId]
+      : userGroup
+        ? userGroup.members.flatMap((member) => (member.id ? [member.id] : []))
+        : []
+
+    for (const affectedUserId of affectedUserIds) {
+      if (typeof catalogCollectionId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { catalogCollectionId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof answerCollectionId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { answerCollectionId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof elementId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { elementId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof courseId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { courseId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof liveQuizId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { liveQuizId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof practiceQuizId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { practiceQuizId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof microLearningId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { microLearningId, userId: affectedUserId },
+          prisma
+        )
+      } else if (typeof groupActivityId !== 'undefined') {
+        await recomputeDerivedPermissions(
+          { groupActivityId, userId: affectedUserId },
           prisma
         )
       }
-      // if ADMIN permissions were revoked, make sure that the user does not have any access request instances assigned anymore
-      else if (
-        previousPermission.permissionLevel === DB.PermissionLevel.ADMIN &&
-        permissionLevel !== DB.PermissionLevel.ADMIN
-      ) {
-        await prisma.accessRequest.deleteMany({
-          where: {
-            objectAdminOrOwnerId: updatedPermission.userId,
-            catalogCollectionId,
-            answerCollectionId,
-            elementId,
-            courseId,
-            liveQuizId,
-            practiceQuizId,
-            microLearningId,
-            groupActivityId,
-          },
-        })
-      }
-    } else if (userGroup) {
+
       // if ADMIN permissions were granted, make sure all group members also get access request instances assigned
       if (
         previousPermission.permissionLevel !== DB.PermissionLevel.ADMIN &&
@@ -2207,10 +2165,10 @@ export async function changeObjectPermissionLevel(
       ) {
         // create access request instances for all members of the user group
         await Promise.all(
-          userGroup.members.map(async (member) => {
+          affectedUserIds.map(async (userId) => {
             await createAccessRequestInstancesNewAdmin(
               {
-                newAdminId: member.id,
+                newAdminId: userId,
                 existingAdminOwnerId: ctx.user.sub,
                 catalogCollectionId,
                 answerCollectionId,
@@ -2232,10 +2190,11 @@ export async function changeObjectPermissionLevel(
         previousPermission.permissionLevel === DB.PermissionLevel.ADMIN &&
         permissionLevel !== DB.PermissionLevel.ADMIN
       ) {
-        await prisma.accessRequest.deleteMany({
+        // fetch the user ids of all admin users of the object (after the permission level modification)
+        const adminOwnerPermissions = await prisma.derivedPermission.findMany({
           where: {
-            objectAdminOrOwnerId: {
-              in: userGroup.members.map((member) => member.id),
+            permissionLevel: {
+              in: [DB.PermissionLevel.ADMIN, DB.PermissionLevel.OWNER],
             },
             catalogCollectionId,
             answerCollectionId,
@@ -2246,7 +2205,34 @@ export async function changeObjectPermissionLevel(
             microLearningId,
             groupActivityId,
           },
+          select: { userId: true },
         })
+        const adminOwnerIds = adminOwnerPermissions.map(
+          (permission) => permission.userId
+        )
+
+        // identify users with revoked ADMIN permissions
+        const usersRevokedAdminPermissions = affectedUserIds.filter(
+          (userId) => !adminOwnerIds.includes(userId)
+        )
+
+        if (usersRevokedAdminPermissions.length > 0) {
+          await prisma.accessRequest.deleteMany({
+            where: {
+              objectAdminOrOwnerId: {
+                in: usersRevokedAdminPermissions, // user might retain ADMIN / OWNER rights through different direct permission
+              },
+              catalogCollectionId,
+              answerCollectionId,
+              elementId,
+              courseId,
+              liveQuizId,
+              practiceQuizId,
+              microLearningId,
+              groupActivityId,
+            },
+          })
+        }
       }
     }
 
@@ -2418,21 +2404,6 @@ export async function revokeObjectAccess(
         : []
 
     for (const affectedUserId of affectedUserIds) {
-      // if the new revoked permission level had ADMIN level, remove any access requests linked to this user (group) as an object owner or admin
-      await prisma.accessRequest.deleteMany({
-        where: {
-          objectAdminOrOwnerId: affectedUserId,
-          catalogCollectionId,
-          answerCollectionId,
-          elementId,
-          courseId,
-          liveQuizId,
-          practiceQuizId,
-          microLearningId,
-          groupActivityId,
-        },
-      })
-
       // update the derived permissions of all affected users
       if (typeof catalogCollectionId !== 'undefined') {
         await recomputeDerivedPermissions(
@@ -2477,6 +2448,54 @@ export async function revokeObjectAccess(
       }
     }
 
+    // if the revoked permission was at ADMIN level, remove any access requests linked to ADMINS using this permission
+    if (permission.permissionLevel === DB.PermissionLevel.ADMIN) {
+      // fetch the user ids of all admin users of the object (after the permission level modification)
+      const adminOwnerPermissions = await prisma.derivedPermission.findMany({
+        where: {
+          permissionLevel: {
+            in: [DB.PermissionLevel.ADMIN, DB.PermissionLevel.OWNER],
+          },
+          catalogCollectionId,
+          answerCollectionId,
+          elementId,
+          courseId,
+          liveQuizId,
+          practiceQuizId,
+          microLearningId,
+          groupActivityId,
+        },
+        select: { userId: true },
+      })
+      const adminOwnerIds = adminOwnerPermissions.map(
+        (permission) => permission.userId
+      )
+
+      // identify users with revoked ADMIN permissions
+      const usersRevokedAdminPermissions = affectedUserIds.filter(
+        (userId) => !adminOwnerIds.includes(userId)
+      )
+
+      // remove any access requests linked to users that lost their ADMIN access to the object
+      if (usersRevokedAdminPermissions.length > 0) {
+        await prisma.accessRequest.deleteMany({
+          where: {
+            objectAdminOrOwnerId: {
+              in: usersRevokedAdminPermissions,
+            },
+            catalogCollectionId,
+            answerCollectionId,
+            elementId,
+            courseId,
+            liveQuizId,
+            practiceQuizId,
+            microLearningId,
+            groupActivityId,
+          },
+        })
+      }
+    }
+
     return deleted
   })
 
diff --git a/packages/graphql/test/access.test.ts b/packages/graphql/test/access.test.ts
index 482467d353..d3d164a5f7 100644
--- a/packages/graphql/test/access.test.ts
+++ b/packages/graphql/test/access.test.ts
@@ -31,7 +31,14 @@ import {
   testCleanup,
   testInitialization,
 } from './helpers.js'
-import { userFive, userFour, userOne, userThree, userTwo } from './userData.js'
+import {
+  userFive,
+  userFour,
+  userOne,
+  userSix,
+  userThree,
+  userTwo,
+} from './userData.js'
 
 describe('Unit tests for object access validation', () => {
   // shared resources used across tests
@@ -42,6 +49,7 @@ describe('Unit tests for object access validation', () => {
   let userThreeCtx: ContextWithUser
   let userFourCtx: ContextWithUser
   let userFiveCtx: ContextWithUser
+  let userSixCtx: ContextWithUser
 
   beforeAll(async () => {
     const { prisma: newPrisma, emitter: newEmitter } = await initializePrisma()
@@ -61,6 +69,7 @@ describe('Unit tests for object access validation', () => {
       userThreeCtx: ctx3,
       userFourCtx: ctx4,
       userFiveCtx: ctx5,
+      userSixCtx: ctx6,
     } = await testInitialization(prisma, emitter)
 
     userOneCtx = ctx1
@@ -68,6 +77,7 @@ describe('Unit tests for object access validation', () => {
     userThreeCtx = ctx3
     userFourCtx = ctx4
     userFiveCtx = ctx5
+    userSixCtx = ctx6
   })
 
   afterEach(async () => {
@@ -2213,6 +2223,187 @@ describe('Unit tests for object access validation', () => {
     expect(catalogRequest5Removed2).toBeNull()
   })
 
+  it('Test that reducing the level of existing mixed permissions from ADMIN level results in the removal of pending access requests for users without other access', async () => {
+    const { publicCatalog } = await seedCatalogCollections(userOneCtx)
+
+    // grant individual ADMIN access to users 2, 4, and 5
+    const permission2 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userTwo.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const permission4 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userFour.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const permission5 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userFive.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+
+    // create a user group with users 3 and 4 and a single participant group for user 5
+    const userGroup1 = await prisma.userGroup.create({
+      data: {
+        name: 'Test User Group 1',
+        members: {
+          connect: [{ id: userThree.id }, { id: userFour.id }],
+        },
+        ownerId: userOne.id,
+      },
+    })
+    const userGroup2 = await prisma.userGroup.create({
+      data: {
+        name: 'Test User Group 2',
+        members: {
+          connect: [{ id: userFive.id }],
+        },
+        ownerId: userOne.id,
+      },
+    })
+
+    // grant group ADMIN permissions to the user groups
+    const groupPermission1 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userGroupId: userGroup1.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const groupPermission2 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userGroupId: userGroup2.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    await recomputeDerivedPermissions(
+      { catalogCollectionId: publicCatalog.id },
+      prisma
+    )
+
+    // create an access request for user 6 with instances for all ADMINS
+    await prisma.accessRequest.createMany({
+      data: [
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userTwo.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userThree.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFour.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFive.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+      ],
+    })
+    const accessRequestCount1 = await prisma.accessRequest.count()
+    expect(accessRequestCount1).toBe(4)
+
+    // lower the individual permission of user 2 -> access request for user 2 should be removed
+    await changeObjectPermissionLevel(
+      {
+        permissionId: permission2.id,
+        permissionLevel: PermissionLevel.WRITE,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount2 = await prisma.accessRequest.count()
+    expect(accessRequestCount2).toBe(3)
+
+    const removedAccessRequest2 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userTwo.id,
+        },
+      },
+    })
+    expect(removedAccessRequest2).toBeNull()
+
+    // lower the group permission of user group 1 (users 3 and 4)
+    // access request for user 3 should be removed, but not for user 4 (retains individual ADMIN access)
+    await changeObjectPermissionLevel(
+      {
+        permissionId: groupPermission1.id,
+        permissionLevel: PermissionLevel.WRITE,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount3 = await prisma.accessRequest.count()
+    expect(accessRequestCount3).toBe(2)
+    const removedAccessRequest3 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userThree.id,
+        },
+      },
+    })
+    expect(removedAccessRequest3).toBeNull()
+
+    const retainedAccessRequest4 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFour.id,
+        },
+      },
+    })
+    expect(retainedAccessRequest4).toBeTruthy()
+    expect(retainedAccessRequest4!.permissionLevel).toBe(PermissionLevel.WRITE)
+
+    // lower the individual permission for user 5 -> access request for user 5 should persist (group ADMIN access)
+    await changeObjectPermissionLevel(
+      {
+        permissionId: permission5.id,
+        permissionLevel: PermissionLevel.WRITE,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount4 = await prisma.accessRequest.count()
+    expect(accessRequestCount4).toBe(2)
+
+    const retainedAccessRequest5 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFive.id,
+        },
+      },
+    })
+    expect(retainedAccessRequest5).toBeTruthy()
+    expect(retainedAccessRequest5!.permissionLevel).toBe(PermissionLevel.WRITE)
+  })
+
   it('Verify that on revocation of individual ADMIN object permissions, access request instances are removed for the previous ADMIN', async () => {
     const { publicCatalog } = await seedCatalogCollections(userOneCtx)
     const [AC1] = await seedAnswerCollections(userOneCtx)
@@ -2568,6 +2759,184 @@ describe('Unit tests for object access validation', () => {
     expect(catalogRequest5Removed2).toBeNull()
   })
 
+  it('Verify that on revocation of mixed ADMIN object permissions, access request instances are removed only for users without other access', async () => {
+    const { publicCatalog } = await seedCatalogCollections(userOneCtx)
+
+    // grant individual ADMIN access to users 2, 4, and 5
+    const permission2 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userTwo.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const permission4 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userFour.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const permission5 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userId: userFive.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+
+    // create a user group with users 3 and 4 and a single participant group for user 5
+    const userGroup1 = await prisma.userGroup.create({
+      data: {
+        name: 'Test User Group 1',
+        members: {
+          connect: [{ id: userThree.id }, { id: userFour.id }],
+        },
+        ownerId: userOne.id,
+      },
+    })
+    const userGroup2 = await prisma.userGroup.create({
+      data: {
+        name: 'Test User Group 2',
+        members: {
+          connect: [{ id: userFive.id }],
+        },
+        ownerId: userOne.id,
+      },
+    })
+
+    // grant group ADMIN permissions to the user groups
+    const groupPermission1 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userGroupId: userGroup1.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    const groupPermission2 = await prisma.permission.create({
+      data: {
+        catalogCollectionId: publicCatalog.id,
+        userGroupId: userGroup2.id,
+        permissionLevel: PermissionLevel.ADMIN,
+      },
+    })
+    await recomputeDerivedPermissions(
+      { catalogCollectionId: publicCatalog.id },
+      prisma
+    )
+
+    // create an access request for user 6 with instances for all ADMINS
+    await prisma.accessRequest.createMany({
+      data: [
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userTwo.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userThree.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFour.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+        {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFive.id,
+          permissionLevel: PermissionLevel.WRITE,
+        },
+      ],
+    })
+    const accessRequestCount1 = await prisma.accessRequest.count()
+    expect(accessRequestCount1).toBe(4)
+
+    // revoke the individual permission of user 2 -> access request for user 2 should be removed
+    await revokeObjectAccess(
+      {
+        permissionId: permission2.id,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount2 = await prisma.accessRequest.count()
+    expect(accessRequestCount2).toBe(3)
+
+    const removedAccessRequest2 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userTwo.id,
+        },
+      },
+    })
+    expect(removedAccessRequest2).toBeNull()
+
+    // revoke the group permission of user group 1 (users 3 and 4)
+    // access request for user 3 should be removed, but not for user 4 (retains individual ADMIN access)
+    await revokeObjectAccess(
+      {
+        permissionId: groupPermission1.id,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount3 = await prisma.accessRequest.count()
+    expect(accessRequestCount3).toBe(2)
+    const removedAccessRequest3 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userThree.id,
+        },
+      },
+    })
+    expect(removedAccessRequest3).toBeNull()
+
+    const retainedAccessRequest4 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFour.id,
+        },
+      },
+    })
+    expect(retainedAccessRequest4).toBeTruthy()
+    expect(retainedAccessRequest4!.permissionLevel).toBe(PermissionLevel.WRITE)
+
+    // revoke the individual permission for user 5 -> access request for user 5 should persist (group ADMIN access)
+    await revokeObjectAccess(
+      {
+        permissionId: permission5.id,
+        catalogCollectionId: publicCatalog.id,
+      },
+      userOneCtx
+    )
+    const accessRequestCount4 = await prisma.accessRequest.count()
+    expect(accessRequestCount4).toBe(2)
+
+    const retainedAccessRequest5 = await prisma.accessRequest.findUnique({
+      where: {
+        catalogCollectionId_userId_objectAdminOrOwnerId: {
+          catalogCollectionId: publicCatalog.id,
+          userId: userSix.id,
+          objectAdminOrOwnerId: userFive.id,
+        },
+      },
+    })
+    expect(retainedAccessRequest5).toBeTruthy()
+    expect(retainedAccessRequest5!.permissionLevel).toBe(PermissionLevel.WRITE)
+  })
+
   it('Verify that any pending access requests are also assigned to new owner on catalog collection ownership transfer', async () => {
     const { publicCatalog } = await seedCatalogCollections(userOneCtx)
 
diff --git a/packages/graphql/test/helpers.ts b/packages/graphql/test/helpers.ts
index 6377eb3dd3..21596235ab 100644
--- a/packages/graphql/test/helpers.ts
+++ b/packages/graphql/test/helpers.ts
@@ -22,14 +22,21 @@ import {
   catalogCollection1,
   catalogCollection2,
 } from './testData.js'
-import { userFive, userFour, userOne, userThree, userTwo } from './userData.js'
+import {
+  userFive,
+  userFour,
+  userOne,
+  userSix,
+  userThree,
+  userTwo,
+} from './userData.js'
 
 // ! General Test Suite Helpers (general setup, user seeding, database connections, cleanup, etc.)
 // #region
 export async function testInitialization(prisma: PrismaClient, emitter) {
   // upsert all users in the database
   await Promise.all(
-    [userOne, userTwo, userThree, userFour, userFive].map(
+    [userOne, userTwo, userThree, userFour, userFive, userSix].map(
       async (user) =>
         await prisma.user.upsert({
           where: { id: user.id },
@@ -45,7 +52,7 @@ export async function testInitialization(prisma: PrismaClient, emitter) {
 
   // verify that users have been created correctly in the database
   const dbUsers = await prisma.user.findMany()
-  expect(dbUsers).toHaveLength(5)
+  expect(dbUsers).toHaveLength(6)
   const actualEmails = dbUsers.map((user) => user.email)
   expect(actualEmails).toEqual(
     expect.arrayContaining([
@@ -54,9 +61,10 @@ export async function testInitialization(prisma: PrismaClient, emitter) {
       userThree.email,
       userFour.email,
       userFive.email,
+      userSix.email,
     ])
   )
-  expect(actualEmails).toHaveLength(5)
+  expect(actualEmails).toHaveLength(6)
 
   // seed the top-level catalog collection with fixed ID
   await prisma.catalogCollection.upsert({
@@ -103,6 +111,10 @@ export async function testInitialization(prisma: PrismaClient, emitter) {
     ...userOneCtx,
     user: { ...userOneCtx.user, sub: userFive.sub },
   }
+  const userSixCtx = {
+    ...userOneCtx,
+    user: { ...userOneCtx.user, sub: userSix.sub },
+  }
 
   return {
     userOneCtx,
@@ -110,6 +122,7 @@ export async function testInitialization(prisma: PrismaClient, emitter) {
     userThreeCtx,
     userFourCtx,
     userFiveCtx,
+    userSixCtx,
   }
 }
 
diff --git a/packages/graphql/test/userData.ts b/packages/graphql/test/userData.ts
index 8840bb6996..0750283fd0 100644
--- a/packages/graphql/test/userData.ts
+++ b/packages/graphql/test/userData.ts
@@ -37,3 +37,11 @@ export const userFive = {
   email: 'fifth@example.com',
   shortname: 'fifthuser',
 }
+
+// mock user 6
+export const userSix = {
+  id: '8ee6a86a-9ef6-4b8a-b7aa-9893111ea9be',
+  sub: '8ee6a86a-9ef6-4b8a-b7aa-9893111ea9be',
+  email: 'sixth@example.com',
+  shortname: 'sixthuser',
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refines the logic for access request removal so that requests are only removed when users do not retain ADMIN object access via alternative permission paths. Key changes include the addition of a mock user (userSix) into test data and updates to helper and access tests to validate the refined behavior, as well as modifications in the sharing service to filter out users that still have ADMIN/OWNER access.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

| File                                          | Description                                                                              |
| --------------------------------------------- | ---------------------------------------------------------------------------------------- |
| packages/graphql/test/userData.ts             | Adds a new mock user (userSix) to support extended test scenarios.                       |
| packages/graphql/test/helpers.ts              | Updates user seeding and expectations to include userSix.                                |
| packages/graphql/test/access.test.ts          | Extends access tests to cover cases with multiple permission sources including userSix.    |
| packages/graphql/src/services/sharing.ts       | Refines logic in changeObjectPermissionLevel and revokeObjectAccess to conditionally remove access requests only when ADMIN/OWNER rights are not retained. |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**packages/graphql/src/services/sharing.ts:2207**
* [nitpick] Consider adding tests that explicitly verify the scenario where a user retains ADMIN access through multiple paths, ensuring that no access requests are incorrectly removed.
```
permissionLevel !== DB.PermissionLevel.ADMIN &&
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:47:09Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🔭 Outside diff range comments (2)</summary><blockquote>

<details>
<summary>packages/graphql/src/services/sharing.ts (1)</summary><blockquote>

`2258-2274`: _⚠️ Potential issue_

**`in: []` leads to runtime error**

When `userGroup.members` all still have ADMIN/OWNER rights the filtered array can be empty, producing:

```prisma
where: { objectAdminOrOwnerId: { in: [] } }
```

Prisma rejects an empty `in` filter (`Invalid value [] for in filter`).  
Guard before calling `deleteMany`:

```ts
const groupMemberIdsToPurge = userGroup.members
  .map(m => m.id)
  .filter(id => !adminOwnerIds.includes(id));

if (groupMemberIdsToPurge.length) {
  await prisma.accessRequest.deleteMany({
    where: {
      objectAdminOrOwnerId: { in: groupMemberIdsToPurge },
      /* other filters */
    },
  });
}
```

</blockquote></details>
<details>
<summary>packages/graphql/test/access.test.ts (1)</summary><blockquote>

`2266-2938`: _🛠️ Refactor suggestion_

**High duplication – factor out a helper**

Both mixed‑permission tests share a long setup flow:

1. create identical permission constellation (3 individual + 2 groups)  
2. create identical access‑request matrix for `userSix`  
3. perform identical three‑step modifications (lower / revoke ind‑2, grp‑1, ind‑5)  
4. assert counts & presence / absence

Extracting this into a parametric utility (e.g. `runMixedAdminRemovalScenario({mutatorFn})`) would:

* reduce ~350 lines to ~80
* keep the assertions close to the behaviour under test instead of the mechanics
* make future edge‑case additions much cheaper.

Given test‑suite size (>4 k LOC) this refactor is worth considering to keep execution time & reviewer effort sustainable.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (8)</summary><blockquote>

<details>
<summary>packages/graphql/test/helpers.ts (3)</summary><blockquote>

`39-41`: **Hard‑coding user array length will keep biting – derive it instead.**

`[userOne, userTwo, …, userSix]` has to be edited in three places (upsert, length assertions, return object) every time another test user is introduced.  
Consider keeping all mock users in a single array, e.g.

```ts
const mockUsers = [userOne, userTwo, userThree, userFour, userFive, userSix];
```

and use `mockUsers.length` for size assertions.  
It eliminates two future maintenance steps.

---

`55-68`: **Assertion duplicates previous check**

`expect(actualEmails).toHaveLength(6)` directly follows `expect(dbUsers).toHaveLength(6)` – the length information is already validated.  You could safely drop the second length assertion without losing coverage and make the intent (value equality) clearer.

---

`114-117`: **DRY up repeated context construction**

Six nearly‑identical `user*Ctx` objects differ only in the `sub`.  A small helper:

```ts
function buildCtx(user) {
  return { ...userOneCtx, user: { ...userOneCtx.user, sub: user.sub } };
}
```

would remove duplication and ensure future changes (e.g. adding new props to the context) propagate automatically.

</blockquote></details>
<details>
<summary>packages/graphql/src/services/sharing.ts (2)</summary><blockquote>

`2161-2181`: **Potential Prisma “undefined filter” pitfall**

The `where` object includes keys whose values may be `undefined` (e.g. `catalogCollectionId`).  
Prisma currently tolerates this, but its behaviour is undocumented and subject to change.  
Safer pattern:

```ts
where: {
  permissionLevel: { in: [ADMIN, OWNER] },
  ...(catalogCollectionId && { catalogCollectionId }),
  ...(answerCollectionId && { answerCollectionId }),
  // …
}
```

This keeps the query future‑proof.

---

`2513-2516`: **Minor micro‑optimisation**

`usersRevokedAdminPermissions` is only used once; avoid the extra allocation:

```ts
await prisma.accessRequest.deleteMany({
  where: {
    objectAdminOrOwnerId: {
      in: affectedUserIds.filter(id => !adminOwnerIds.includes(id)),
    },
    // …
  },
});
```

(with the above empty‑array guard).  
Not critical, but keeps the code concise.

</blockquote></details>
<details>
<summary>packages/graphql/test/access.test.ts (3)</summary><blockquote>

`2266-2271`: **Polish the test title for clarity**

`"Test that reduction the level of existing mixed permissions from ADMIN level …"` contains a small grammatical hic‑cup (“reduction the level”).  
Replacing it with “reducing” makes the purpose clearer to future readers.

```diff
-it('Test that reduction the level of existing mixed permissions from ADMIN level results in a removal of all pending access requests for users without other access', async () => {
+it('Test that reducing the level of existing mixed permissions from ADMIN results in removal of pending access requests for users without other ADMIN access', async () => {
```

---

`2292-2321`: **Consider generating the access‑request seed programmatically**

The four almost‑identical `createMany` objects only differ in `objectAdminOrOwnerId`.  
Building the array with `[{id:userTwo.id}, …]` in a small helper would:

* cut duplication
* ease maintenance if the shape ever changes
* make it obvious that the test covers all admins exhaustively.

Only a stylistic suggestion – the current code is functionally correct.

---

`2762-2768`: **Repeat of grammatical nit in second mixed‑revocation test**

Same “reduction the level” wording here – recommend applying the corrected phrasing for consistency.

```diff
-it('Verify that on revocation of mixed ADMIN object permissions, access request instances are removed only for users without other access', async () => {
+it('Verify that revoking mixed ADMIN object permissions removes access requests only for users without other ADMIN access', async () => {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between aee8d376559f92d3b862f1b48214ec806335803b and c42f531a3af5e81529e2d903aaa0c086214e1b3a.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `packages/graphql/src/services/sharing.ts` (4 hunks)
* `packages/graphql/test/access.test.ts` (5 hunks)
* `packages/graphql/test/helpers.ts` (4 hunks)
* `packages/graphql/test/userData.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (2)</summary>

<details>
<summary>packages/graphql/test/helpers.ts (1)</summary><blockquote>

<details>
<summary>packages/graphql/test/userData.ts (1)</summary>

* `userSix` (42-47)

</details>

</blockquote></details>
<details>
<summary>packages/graphql/test/access.test.ts (6)</summary><blockquote>

<details>
<summary>packages/graphql/src/lib/context.ts (1)</summary>

* `ContextWithUser` (32-41)

</details>
<details>
<summary>packages/graphql/test/helpers.ts (1)</summary>

* `testInitialization` (36-127)

</details>
<details>
<summary>packages/graphql/test/userData.ts (6)</summary>

* `userTwo` (10-15)
* `userFour` (26-31)
* `userFive` (34-39)
* `userThree` (18-23)
* `userOne` (2-7)
* `userSix` (42-47)

</details>
<details>
<summary>packages/graphql/src/schema/sharing.ts (1)</summary>

* `PermissionLevel` (17-19)

</details>
<details>
<summary>packages/util/src/permissions.ts (1)</summary>

* `recomputeDerivedPermissions` (39-135)

</details>
<details>
<summary>packages/graphql/src/services/sharing.ts (2)</summary>

* `changeObjectPermissionLevel` (2034-2332)
* `revokeObjectAccess` (2334-2545)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (5)</summary>

* GitHub Check: SonarCloud
* GitHub Check: test
* GitHub Check: check
* GitHub Check: cypress-run
* GitHub Check: Analyze (javascript)

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>packages/graphql/test/userData.ts (1)</summary>

`40-47`: **Addition looks correct – just confirm UUID & short‑name uniqueness.**

`userSix` follows the same structure as the other mocks and nothing is obviously wrong.  
Please double‑check that the UUID / sub / shortname do not collide with any existing seeded users in other helper fixtures to avoid flaky tests.

</details>
<details>
<summary>packages/graphql/test/access.test.ts (2)</summary>

`34-41`: **Import extension looks good**

`userSix` is correctly imported alongside the other fixtures and will be required by the new mixed‑permission tests further down.  
No issues spotted here.

---

`52-81`: **Context plumbing correctly extended**

`userSixCtx` is threaded through the shared test bootstrap exactly like the existing five contexts – this will keep the helper’s return type consistent and avoids future surprise errors.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T21:50:01Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refines the logic for removing access requests so that they are only removed when a user completely loses ADMIN or OWNER permissions, while also expanding test coverage for these scenarios.  
- Added a new test user (userSix) and updated test initialization and expectations accordingly.  
- Extended unit tests in access.test.ts to cover various scenarios involving changes to individual and group permissions.  
- Modified the sharing service (changeObjectPermissionLevel and revokeObjectAccess) to recompute derived permissions and update access requests based on the new permission logic.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File                                               | Description                                                                                         |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| packages/graphql/test/userData.ts                  | Added new test userSix for extended test coverage.                                                 |
| packages/graphql/test/helpers.ts                   | Updated user seeding and test expectations to include userSix.                                      |
| packages/graphql/test/access.test.ts               | Added new tests verifying the removal or retention of access requests in various permission changes.|
| packages/graphql/src/services/sharing.ts           | Adjusted logic for triggering recomputation of derived permissions and updating access requests.    |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**packages/graphql/src/services/sharing.ts:2470**
* [nitpick] Consider renaming 'adminOwnerIds' to 'currentAdminOwnerIds' to better indicate that these IDs represent the users who currently hold ADMIN/OWNER permissions after the change.
```
const adminOwnerIds = adminOwnerPermissions.map((permission) => permission.userId)
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T22:02:30Z

Review comments:
diff_hunk: @@ -2477,6 +2486,52 @@ export async function revokeObjectAccess(
       }
     }
 
+    // if the revoked permission was at ADMIN level, remove any access requests linked to ADMINS using this permission
+    if (permission.permissionLevel === DB.PermissionLevel.ADMIN) {
+      // fetch the user ids of all admin users of the object (after the permission level modification)
+      const adminOwnerPermissions = await prisma.derivedPermission.findMany({
+        where: {
+          permissionLevel: {
+            in: [DB.PermissionLevel.ADMIN, DB.PermissionLevel.OWNER],
+          },
+          catalogCollectionId,
+          answerCollectionId,
+          elementId,
+          courseId,
+          liveQuizId,
+          practiceQuizId,
+          microLearningId,
+          groupActivityId,
+        },
+        select: { userId: true },
+      })
+      const adminOwnerIds = adminOwnerPermissions.map(
+        (permission) => permission.userId
+      )
+
+      // identify users with revoked ADMIN permissions
+      const usersRevokedAdminPermissions = affectedUserIds.filter(
+        (userId) => !adminOwnerIds.includes(userId)
+      )
+
+      // remove any access requests linked to users that lost their ADMIN access to the object
+      await prisma.accessRequest.deleteMany({
+        where: {
+          objectAdminOrOwnerId: {
+            in: usersRevokedAdminPermissions,
+          },
+          catalogCollectionId,
+          answerCollectionId,
+          elementId,
+          courseId,
+          liveQuizId,
+          practiceQuizId,
+          microLearningId,
+          groupActivityId,
+        },
+      })
+    }
path: packages/graphql/src/services/sharing.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Same empty‑`in` issue in `revokeObjectAccess`**

`usersRevokedAdminPermissions` can be `[]`, triggering the same Prisma error.  
Apply the same guard pattern before `deleteMany`.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-21T21:50:00Z
author_association: NONE
diff_hunk: @@ -2213,6 +2223,187 @@ describe('Unit tests for object access validation', () => {
     expect(catalogRequest5Removed2).toBeNull()
   })
 
+  it('Test that reduction the level of existing mixed permissions from ADMIN level results in a removal of all pending access requests for users without other access', async () => {
path: packages/graphql/test/access.test.ts
user: login: Copilot
body: [nitpick] Consider rephrasing the test description for clarity, for example: 'Test that reducing the level of existing mixed permissions from ADMIN level results in the removal of pending access requests for users without other access.'
```suggestion
  it('Test that reducing the level of existing mixed permissions from ADMIN level results in the removal of pending access requests for users without other access', async () => {
```
created_at: 2025-04-21T22:02:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-21T21:46:50Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: rate limited by coderabbit.ai -->

> [!WARNING]
> ## Rate limit exceeded
> 
> @sjschlapbach has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait **7 minutes and 31 seconds** before requesting another review.
> 
> <details>
> <summary>⌛ How to resolve this issue?</summary>
> 
> After the wait time has elapsed, a review can be triggered using the `@coderabbitai review` command as a PR comment. Alternatively, push new commits to this PR.
> 
> We recommend that you space out your commits to avoid hitting the rate limit.
> 
> </details>
> 
> 
> <details>
> <summary>🚦 How do rate limits work?</summary>
> 
> CodeRabbit enforces hourly rate limits for each developer per organization.
> 
> Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.
> 
> Please see our [FAQ](https://docs.coderabbit.ai/faq) for further information.
> 
> </details>
> 
> <details>
> <summary>📥 Commits</summary>
> 
> Reviewing files that changed from the base of the PR and between b4724f903a40735bc7dd21e8977e6fe12fcba0dd and d3702cabd6fc794affb8f6fb5e32265c90692cf9.
> 
> </details>
> 
> <details>
> <summary>📒 Files selected for processing (1)</summary>
> 
> * `packages/graphql/test/access.test.ts` (5 hunks)
> 
> </details>

<!-- end of auto-generated comment: rate limited by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes introduce refined logic for handling access request instances in scenarios where ADMIN or OWNER permissions on objects are modified or revoked. Specifically, the code in the sharing service now ensures that access request instances are only deleted for users who fully lose their ADMIN/OWNER access, accounting for cases where users may retain such permissions through other individual or group assignments. The test suite and helper utilities are extended to cover these scenarios, including the addition of a sixth user to facilitate comprehensive testing of permission changes and their effects on access requests.

## Changes

| Files/Paths                                                          | Change Summary |
|----------------------------------------------------------------------|---------------|
| `packages/graphql/src/services/sharing.ts`                           | Refined logic in `changeObjectPermissionLevel` and `revokeObjectAccess` to delete access requests only for users who lose all ADMIN/OWNER permissions, considering both individual and group permissions. |
| `packages/graphql/test/access.test.ts`                               | Added two test cases covering mixed individual and group ADMIN permission reductions and revocations, verifying correct deletion or retention of access requests. Updated imports and initialization for new test user. |
| `packages/graphql/test/helpers.ts`                                   | Updated test initialization to seed a sixth user (`userSix`) and provide a corresponding user context (`userSixCtx`) for use in tests. |
| `packages/graphql/test/userData.ts`                                  | Added a new exported mock user object `userSix` with unique identifiers for testing purposes. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant SharingService
    participant Database

    User->>SharingService: Change or revoke ADMIN/OWNER permission
    SharingService->>Database: Update permission
    SharingService->>Database: Fetch all users with ADMIN/OWNER permissions post-modification
    alt User/group lost all ADMIN/OWNER permissions
        SharingService->>Database: Delete access requests for affected users/groups
    else User/group retains ADMIN/OWNER via other permissions
        SharingService-->>Database: Do not delete access requests
    end
```

## Possibly related PRs

- [uzh-bf/klicker-uzh#4629](https://github.com/uzh-bf/klicker-uzh/pull/4629): Also modifies deletion logic in `revokeObjectAccess` and `changeObjectPermissionLevel`, but without the nuanced permission checks for group/individual overlaps.
- [uzh-bf/klicker-uzh#4628](https://github.com/uzh-bf/klicker-uzh/pull/4628): Refactors permission handling and access request creation in the same functions, focusing on unification and extension, while this PR focuses on selective deletion logic.

## Suggested labels

`enhancement`, `size:L`

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- DwQgtGAEAqAWCWBnSTIEMB26CuAXA9mAOYCmGJATmriQCaQDG+Ats2bgFyQAOFk+AIwBWJBrngA3EsgEBPRvlqU0AgfFwA6NPEgQAfACgjoCEejqANiS4AzeAA8AFNzQMA1mlKIA9ESrdYAEcLAEouChI7ckgLfCJ4BkgbfD5XBmlkCMDsaVxICOZ8CTQLSAJIARIY+GZ1OjL8RjREaUgAd1hKKuwWimRaRox8PIjcbSwAQQARAFkASQA5fmFRPLSMstgKfGwiWH5cTr4XQ8QDBexmSoouABYANgBmACYDAFVerkQhRAZYCzQ3AErlgRn0xnAUDI9HwNhwBGIZGUNHoTFY7C4vGWIjEklacgUSioqnUWh04JMUDgqFQmHhhFI5CoKIU6IwnHyaDakEQl2YaAo8gJTCJKjUmm0ujAhghpgMLncnmkvn8QQs3kQFAYGsoEgSysQsAF8AwRA0uEQHAMACJbQYAMT2yATOaIpnUeq81gC+SwxhG03SMydf2YLz5SImqqHKoB2gWE1Efhw7iUWqIRDwfBYKxSUp/MOtTD0VMUdOZ7MRiT4BjULMYTIkAEs8rTeYLMC5ps8NNICsNlBYGOQAAGBcDAHkVmIAAq9jP1gAyJDzI/QGHoI4i1bcJCnONwEwY6Qza5s2AwuOziA0kDmWDHAdI+9Wc7LfaXK6bI4ANJsqiKVRDNyozYBQA4kAKFjyPAcLDtg3C0B6Jbzv2kADK0Qx5CQ9hIJod55Gi3B4K0w5oDYNirPUPSUHeUzIDY2zMJAJDqEc6A8omViQDRfApBxvGQH4OzcOu9ARERJH9JQeIoe+C7Xkk/GQX8t4AOqdJMsyLD28n9sgApVH4mAon+6wZhG2S5IOiBjJeRYRIwETIUpfBkRRVH0LxjiICE/CpBYpRsFclDIH68G9EJ2wIepmnOtpSyluW9YGY5274LutB/iatmQTCcIXkwG7qPWJTQehTYkOIproMeGxZDktkMfxZXoB5YjUb0YV8MJCGQMF1yIH+w6AUkVV/EWgWMGBETsvF7b+ZAE5qQsACiABKPFdcm/7YqsbU0G5IYIUhNDZUo7KwfArQdAk+zkZRHVedtsS2fNOkUPAewWmZG4VVYNAGXVFkNbkYUYOVyRHfgLRbaFt7UsgvBfuy0kA4mtUno2VlNa5cN9O0sCNKM4zvQs3jLWtm2fd9yCHNFewHOxSUfteGhGAAyjU8AAhQ0HZQ+6W7i+YhHljv67cjeo7MghXZrQJXZiU/1VfWO3mdjjUWnjrUPZ5+PIKgBRFHQt4TDYh0q9VSbDizClYMWEaSdbFWfVIcnJYpUNtY9LK8UNKBwSGQv1HbaFtM0ZMxF+FjDSGo2UbgE0GVNDAzewZMUytG0Gztw6CAef5KIdtTkMgt1/D7+v+zEMN5G2H1fbAP1iVbRbA5rYP8BD8je4cMPdF1CMIMgZC8hEQNY5ZWupVU2blcb7t4zXHSNLgFDYOVr31wlnYx5jGTswYE5SFQgVxwBT6tCa6+KNg6QKMV4hK6USjo5Wfoa9PXfAi0MJDiGVwuBsDK1emFOEDdyaUxzmHFK2VmC8CKBjZGDAkBqxNLpT2WB0q1mfg7P6hQFZ2FwWrOMCZTRHyMI6Z0FhDp1kUuUYcSgGC83oQOP0OFuApBZPxYiAgEyJHYCVIMBgoALEaOOcMjCQyZiIBgagYFWgcPsFwigPDjjYH4QkFil1xA3S6P1NAShh5VEIVdEhilDKDkOvI0o0iqg4Twhjc8l48HIEfIWEWuA3xYOXKuVuW4VwZT3NOQ8Hc1wYJHAqDwXgVSAjVBqLUOoKB6hPBqI0n0KEWhHOzW01owRymiUqHwxkAjBG8IDXA3gNbmlyOaS0No7TUJdG6ZEno+Q+h2pIkRcBozWV5HUPGBd9pf2KAmU66CHw1MqfUtcEdR72BoBueoBIjEKxqrgNogwSDciIhETSmYpBlGsrWFozU05/27rtSoRppZ8ThF/UGuMOhkEwazAcViIi0HvvUfiId6AYN+GQY0MNBzVgsHqGqtR7D1BNArPU3zlaO16qJSBbz7Y3iMAARhMUkeAfQ8jkG5JUyAp8rqkSNHkF5ACqiwMrF2UoyinFQocPUNFdKByOAEMMfYcLJDwERaUGujtBIot8igRs3z0j0EYiwKO5Q0C1zaLRBlZkpqpmKjVR5JAcba2aIgGs8AXJtDYpsAeZNZ4RkKEveevd+IryJuhQYwwIxjAwTy2iaKv70x2IzIhlFZp5A5TeGAIYSUtG1n1PlCKQGlHZahFKy9trPD/LcX69AACs6aooiSjsGvGYrH5uowBjGujxU3ZozbeOYhFnKAwPiDHVM8k20XuNUDAmUGjoCmjGWGkCQ29KwOoHkTahFlXkLEZVBN83e0Es8SAjho0CtjSEP8oroqiSxQustrdbirtboJDNC6l2Cv3RNdwGNDjUAbZ3XGs7Ipts+SQa1vye6E1ecOeNel0EMU3lvOu2aSZRgBXCD1fAvUdyteMTFBhni4paEVegRLjlvTGQKj0dNKVXJwWwhdREAZVEXiUPyfpoVsoSui/Sx7ioxqRX9FF+7tW6psnZE86A0rPpNv/SG9rtqr1rrDVqEGsbVryJmWovNyoRtlqJE9sa80Jq9rx0KkAU2QDTa3LNOa+pfqwc1HqG7y6mrLRWzT2aGB1vblPJ52t72tp1lNfton/zYKCbuQ2NHl3Kw5S2vgamQ7IBRZRtWdmDO5qxYBtz19POCuC5WULkAtNkpsLIS9WGmPNoS4+jjL7uPyGpbtJgFAJ5cM1UmXT7yJVVmCfQR2Qwmaeoo9q/kOUj4TFoArPB46L4oAQdw1uJKTQlRKPAAAXrh0a8yeKIRcuUE0LDsBKFHLxLm9g1yOxHCthwABhXAa2u28m4KovIvagI7JQxaI+vTYaVIWUs1EJsqCkB2j5r5988Gtxwx9oF8jPqgpNOCyFSYyMApi/J5FG6FPfssX9GM+LesKjyJWDV6ykwZbBn+MeYE0vXvR7jKxRWJK4AXpxm1fAgPkHoL/V9UGhs1WE4fIwVCnQTFociRN9iKosIFGw8BLEVHcN+RorRgjdHXTOKI50HX6jIZJac0xVUia0C4JAAwkBRzqEcAAcmgNZK9Iw6DvbVsOBlO1HG2QxiDuLA5ZXMTRabiem9tYYMVURxlDz1XQgxnj2zymCYmv7ngBrqQO5a7MogWQl4F1+QALx6EgAAbw0MngAviENcqv1eRKUL8T6lQ1z8JrG4HaI5pl1OyWCKXSgkPnbl80BX/dlfq7Vxr3A2uABqMkUubGvZWL7atSOsvoGi4ZYhrcBx9yxzAbGn25e7jxvgK82I7CRzGEPWMw/oAj1Hxwsf49J9T+n5vWeHw54s/AfPFRYjuBL2X2yszGn5NEYU1wMTlSlISZU7wnQLClhvBaK0PJB0FnV0RkNpegL0fkQULpK+CXXpC7QcYbBMcbD7FxK8LAKbE6WbRoebCwRbKoOkNZRWWxfGP8TbXoVbCWDBJhagFQevEdOgZBbYETUNGkDAQHWFPrNRDGcgygSgpIJiXaQSU6RVQhTeEgMyDrHgrbfbTnAUKgX0AqbacoSoabXoFkG+NeEMEQ6nbNIxIQHoF2MiDMSgNxLtThfaTMewXOHKGgIxHaOwKQXFZLBIXDP0QSZ9bQCwcuSOEoA1abU6eoObS8PApbXgigSgrXUeFrCwM2KQrrQKWQMySAZDQoG/QSIqGgRZMgmQ3bNbP8KbCzSCFkYUWIEtDZEMc3F2cIiccgPI9bP6XkAQC3YBIw46SKAVIROwWiAPfYcIyI9xJotcRBUsXAWQYeVAZDTInCKlXw6XegTnUCcCX5UJAQuVYcIbcQEbFAkLQqPBW8cRYPWueIRIfiTI7YUoGwSdUMQMcuAxfkYxJnYAmhOhMwznZhVhMw5RY7IXHgTRARHRcQPRCXKkSogXNReoZoSPRINAj7EcSpe8JAsbNhZwT6RAfkLgGcNE/kbbBMdgTHWoXAQ6I/SJIpWJD/cpL/H/P/WZdoSOMxbohYnAkI/A5bCghwCJIdSNSKFoRgmqWIE4gbYmKqMCfBdAeI0qIVSKaYxZNkvgnbPbHJR/ApMAIwMk9/VUSk3IbwXiKYWg+pQAppEA1pZkdpb0aAv0bpCXCYFI87NI4vQSUfPIfojkukgyeYrtYcElEQwcOUigPUsYWZCYsKVY3A/AzDWlbYUY8XDXWgCWEcIYsgzwnmCWDbQ0bheRNgOZYzEtKyUlEoRqdCZlMfW3XaKo5xPEfqIvA2A4xoMDG4qRRocsmqH0/iAU7RKdUxIxEgShZ41nV4jnLQqoD4nnL4uEThQXGEYXAEoRYEyvVacElkIqWyEycU6vLgF0/bUk1/YpOJMpdUL/XU/U7JdoU1EY0wmMkcBPFAJvWyTJIgP8Jor4deRMAkrw58+8x8omNRTM6wHkF8mqFPEcZU5/GUSkHRfKekE0lyNENgdkcILkHkDpaA4URQZQEkCUckaUAwcC2C9QAAfQFUQHwu3GumVVoHwpXLUSlDAshEYFuGeBsAzUeCxTQEeHIgzRIAAA4sUM1ngABOEgZ4WgfigABnYrQDQFEoYFEq4vuGeCxVuBICxQEHYpopwropIHuAEC4tuFuCQmeFUtEoEFEvuAzQAHZzLbg0BzKbBzKM0OsuLmKjETKGB7gbB3K1KKQ6KBBbhzLnhbgbAxL2LbhRLzLHgM0BAGBzKOsFLuL+LLKtLKIsVGKGBgRRKOt1KNKIBWRCTCLaBiLSKdk6B8LoQaKgA -->

<!-- internal state end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=uzh-bf/klicker-uzh&utm_content=4632):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-21T22:11:36Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=uzh-bf_klicker-uzh&pullRequest=4632) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [75.5% Duplication on New Code](https://sonarcloud.io/component_measures?id=uzh-bf_klicker-uzh&pullRequest=4632&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=uzh-bf_klicker-uzh&pullRequest=4632)


author_association: NONE
user: login: cypress[bot]
created_at: 2025-04-21T22:30:31Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/y436dx?utm_source=github&utm_medium=PASSED&utm_campaign=project">klicker-uzh</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #4923</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#4923</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/uzh-bf/klicker-uzh/commit/6bb150af85ab2906bdec7ff15685abe90fa600f2">6bb150af85 <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge d3702cabd6fc794affb8f6fb5e32265c90692cf9 into aee8d376559f92d3b862f1b48214...</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/y436dx?utm_source=github&utm_medium=PASSED&utm_campaign=project">klicker-uzh</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/y436dx/branches/fix-access-request-removal/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=4632">fix-access-request-removal</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#4923</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>34m 29s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/uzh-bf/klicker-uzh/commit/6bb150af85ab2906bdec7ff15685abe90fa600f2">6bb150af85 <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge d3702cabd6fc794affb8f6fb5e32265c90692cf9 into aee8d376559f92d3b862f1b48214...</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Julius Schlapbach</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/y436dx/runs/4923/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>499</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/y436dx/branches/fix-access-request-removal/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=4632">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE