Skip to content

feat: classroom control #52

Merged
merged 34 commits into from
Apr 30, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5121fd3
update docs
Apr 22, 2026
0b3a99d
save classroom in pupilProfile
Apr 22, 2026
56b6d2d
Merge branch 'main' into classroom-fixes
marikola Apr 22, 2026
4d07726
fix
Apr 22, 2026
71f7a19
fix tests
Apr 22, 2026
68f30ba
fix tests
Apr 23, 2026
017869d
let removepupil remove classroom from pupilprofile
Apr 23, 2026
1586cfe
change id to classroomId in response
Apr 23, 2026
ed47243
spelling
Apr 23, 2026
619421f
update classroom response to send teacher emails
Apr 23, 2026
b4e6988
update classroom detail response to send pupil status
Apr 23, 2026
786dc42
Add membership status to pupil profile
Apr 24, 2026
cc77c5e
update branch
Apr 27, 2026
a257152
update imports
Apr 27, 2026
4664c4c
move adding classroom to pupil profile to happen only when pupil has …
Apr 28, 2026
bb8bf82
Merge branch 'main' into classroom-control
marikola Apr 28, 2026
1bf4a2d
update function name
Apr 28, 2026
d8e41a1
fix merge breaks
Apr 28, 2026
02b22ce
update tests
Apr 28, 2026
160a380
spotlessApply
Apr 28, 2026
c942efa
Merge branch 'main' into classroom-control
marikola Apr 30, 2026
cbb4f44
Assume only one membership per pupil
Apr 30, 2026
03cda77
Merge remote-tracking branch 'origin/classroom-control' into classroo…
Apr 30, 2026
82dd76e
Assume only one membership per pupil
Apr 30, 2026
b4b91bc
fix test
Apr 30, 2026
a00b461
Merge branch 'main' into classroom-control
marikola Apr 30, 2026
39303f0
update branch
Apr 30, 2026
294e76a
Merge remote-tracking branch 'origin/classroom-control' into classroo…
Apr 30, 2026
76572c9
spotless
Apr 30, 2026
9f6cfac
update tests
Apr 30, 2026
7e7ce11
Merge branch 'main' into classroom-control
marikola Apr 30, 2026
12fbc02
fix merge issue
Apr 30, 2026
b01b768
move repository stuff to service
Apr 30, 2026
dd9b263
spotless
Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import edu.ntnu.idi.idatt.backend.classroom.application.JoinClassroomResult
import edu.ntnu.idi.idatt.backend.classroom.application.OwnedClassroomSummary
import edu.ntnu.idi.idatt.backend.classroom.application.UpdateClassroomCommand
import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus
import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress
import edu.ntnu.idi.idatt.backend.iam.domain.User

/**
Expand Down Expand Up @@ -74,6 +76,10 @@ fun Classroom.toResponse(): ClassroomResponse =
title = title,
description = description,
joinCode = joinCode ?: "",
teacherEmails = teacherMemberships.mapNotNull { it.teacher?.email },
pupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE },
pendingPupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING },
teacherCount = teacherMemberships.size,
status = status.name,
ownerTeacherId = ownerTeacherId,
teachers = teacherResponses(),
Expand All @@ -87,13 +93,16 @@ fun Classroom.toResponse(): ClassroomResponse =
*/
fun Classroom.toDetailResponse(
pupilDisplayNames: Map<Long, String> = emptyMap(),
pupilProgressByUserId: Map<Long, edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress> = emptyMap(),
pupilProgressByUserId: Map<Long, PupilGameProgress> = emptyMap(),
): ClassroomDetailResponse =
ClassroomDetailResponse(
id = id,
classroomId = id,
title = title,
description = description,
joinCode = joinCode ?: "",
pupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE },
pendingPupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING },
teacherCount = teacherMemberships.size,
status = status.name,
ownerTeacherId = ownerTeacherId,
teachers = teacherResponses(),
Expand All @@ -106,6 +115,7 @@ fun Classroom.toDetailResponse(
ClassroomPupilResponse(
userId = pupilId,
displayName = pupilDisplayNames[pupilId] ?: pupil.username ?: "Unknown",
pupilStatus = membership.status,
currentLevel = progress?.currentLevel ?: 1,
completedStopsCount = progress?.completedStopsCount ?: 0,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package edu.ntnu.idi.idatt.backend.classroom.api
/**
* DTO for response to retrieving classroom details.
*
* @param id the id of the classroom.
* @param classroomId the id of the classroom.
* @param title the title of the classroom.
* @param description the description of the classroom.
* @param joinCode the code for joining the classroom.
Expand All @@ -13,10 +13,13 @@ package edu.ntnu.idi.idatt.backend.classroom.api
* @param pupils a list of the pupils that are members of the classroom.
*/
data class ClassroomDetailResponse(
val id: Long,
val classroomId: Long,
val title: String,
val description: String?,
val joinCode: String,
val pupilCount: Int,
val pendingPupilCount: Int,
val teacherCount: Int,
val status: String,
val ownerTeacherId: Long,
val teachers: List<ClassroomTeacherResponse>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package edu.ntnu.idi.idatt.backend.classroom.api

import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus

/**
* DTO for response to retrieving pupil information for a classroom.
*
* @param userId the id of a pupil in the classroom.
* @param displayName the name of a pupil in the classroom.
* @param pupilStatus the pupils pending/approved status.
*/
data class ClassroomPupilResponse(
val userId: Long,
val displayName: String,
val pupilStatus: ClassroomPupilStatus,
val currentLevel: Int,
val completedStopsCount: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package edu.ntnu.idi.idatt.backend.classroom.api
* @param title the title of a classroom.
* @param description the optional description of a classroom.
* @param joinCode the code to join the classroom.
* @param teacherEmails a list of ids of teahcers belonging to the classroom by email.
* @param status whether the classroom is active or inactive.
* @param ownerTeacherId the id of the teacher who owns the classroom.
* @param teachers a list of teachers belonging to the classroom.
Expand All @@ -17,6 +18,10 @@ data class ClassroomResponse(
val title: String,
val description: String?,
val joinCode: String,
val teacherEmails: List<String>,
val pupilCount: Int,
val pendingPupilCount: Int,
val teacherCount: Int,
val status: String,
val ownerTeacherId: Long,
val teachers: List<ClassroomTeacherResponse>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package edu.ntnu.idi.idatt.backend.classroom.application

import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilMembership
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus
import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository
Expand Down Expand Up @@ -124,7 +125,7 @@ class ClassroomService(
*
* @param command command with classroom code and authenticated user subject
* @throws ResponseStatusException when classroom or user does not exist, user is not a pupil,
* user is already a member, or classroom is inactive
* user is already a member of the classroom, user is already a member of a classroom, or classroom is inactive
*/
@Transactional
fun joinClassroom(command: JoinClassroomCommand): JoinClassroomResult {
Expand All @@ -144,12 +145,18 @@ class ClassroomService(
throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member")
}

val hasMembership = classroomRepository.findAllByPupilMembershipsPupilId(userId).flatMap { it.pupilMemberships }.any { it.pupil?. id == userId }
if (hasMembership) {
throw ResponseStatusException(HttpStatus.CONFLICT, "User is already a member in a classroom")
}

classroom.addPupilMembership(user)
try {
userProfileService.updateCurrentClassroom(userId, classroom)
} catch (_: NoSuchElementException) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found")
}

classroomRepository.save(classroom)
pupilGameProgressService.createPupilGameProgressEntry(
pupilId = userId,
Expand Down Expand Up @@ -192,6 +199,8 @@ class ClassroomService(
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found in classroom")
}

userProfileService.updateCurrentClassroom(pupilUserId, classroom)

classroomRepository.save(classroom)
}

Expand All @@ -216,6 +225,8 @@ class ClassroomService(
classroomRepository.findById(classroomId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")

userProfileService.updateCurrentClassroom(pupilUserId, null)

try {
classroom.removePupil(pupilUserId)
} catch (e: IllegalArgumentException) {
Expand Down Expand Up @@ -352,6 +363,28 @@ class ClassroomService(
classroomRepository.deleteById(classroomId)
}

/**
* Finds a pupil membership based on the pupil's id.
*
* @param userId persisted pupil user id
* @return pupil membership, or `null`when no membership exists
* @throws IllegalStateException if pupil has more than one classroom membership
*/
@Transactional(readOnly = true)
fun findPupilMembership(userId: Long): ClassroomPupilMembership? {
val memberships =
classroomRepository
.findAllByPupilMembershipsPupilId(userId)
.flatMap { it.pupilMemberships }
.filter { it.pupil?.id == userId }

if (memberships.size > 1) {
throw IllegalStateException("Invariant violated: Pupil has multiple classroom memberships")
}

return memberships.firstOrNull()
}

/**
* Normalizes a classroom join code by trimming whitespace and converting to uppercase.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,12 @@ interface ClassroomRepository : JpaRepository<Classroom, Long> {
nativeQuery = true,
)
fun findAllWithAverageTotalScore(): List<ClassroomAverageTotalScoreView>

/**
* Returns classroom memberships for a pupil based on the pupils id.
*
* @param userId the id of the pupil being checked.
* @return a list of classrooms the pupil is a member of.
*/
fun findAllByPupilMembershipsPupilId(userId: Long): List<Classroom>
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package edu.ntnu.idi.idatt.backend.users.profile.api

import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfile
import edu.ntnu.idi.idatt.backend.users.profile.application.PupilProfileDetails
import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfileService.CurrentUserProfile
import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfileService.PupilProfileDetails
import edu.ntnu.idi.idatt.backend.users.profile.application.UpdateCurrentUserDisplayNameCommand

/**
Expand All @@ -20,6 +20,7 @@ fun CurrentUserProfile.toCurrentUserProfileResponse(): CurrentUserProfileRespons
PupilProfileResponse(
displayName = it.displayName,
currentClassroomId = it.currentClassroomId,
membershipStatus = it.membershipStatus,
totalXp = it.totalXp,
currentLevel = it.currentLevel,
hasCompletedOnboarding = it.hasCompletedOnboarding,
Expand All @@ -43,6 +44,7 @@ fun PupilProfileDetails.toPupilProfileResponse(): PupilProfileResponse =
PupilProfileResponse(
displayName = displayName,
currentClassroomId = currentClassroomId,
membershipStatus = membershipStatus,
totalXp = totalXp,
currentLevel = currentLevel,
hasCompletedOnboarding = hasCompletedOnboarding,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class CurrentUserProfileResponse(
data class PupilProfileResponse(
val displayName: String,
val currentClassroomId: Long?,
val membershipStatus: String? = null,
val totalXp: Int,
val currentLevel: Int,
val hasCompletedOnboarding: Boolean,
Expand Down
Loading