Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -2,7 +2,6 @@ package edu.ntnu.idi.idatt.backend.classroom.api

import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomService
import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser
import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository
import jakarta.validation.Valid
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
Expand All @@ -21,8 +20,13 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/v1/classrooms")
class ClassroomController(
private val classroomService: ClassroomService,
private val pupilProfileRepository: PupilProfileRepository,
) {
/**
* Lists classrooms owned by the authenticated teacher or all classrooms for admins.
*
* @param currentUser authenticated teacher or admin principal.
* @return classroom ownership summaries for the caller.
*/
@GetMapping("/owned")
fun listOwnedClassrooms(
@AuthenticationPrincipal currentUser: CurrentUser,
Expand All @@ -31,6 +35,13 @@ class ClassroomController(
.listOwnedClassrooms(currentUser.subject)
.map { it.toOwnedClassroomResponse() }

/**
* Retrieves one classroom ownership summary for a teacher or admin.
*
* @param currentUser authenticated teacher or admin principal.
* @param classroomId classroom id to retrieve.
* @return classroom ownership summary.
*/
@GetMapping("/owned/{classroomId}")
fun getOwnedClassroom(
@AuthenticationPrincipal currentUser: CurrentUser,
Expand Down Expand Up @@ -96,22 +107,17 @@ class ClassroomController(
@PathVariable id: Long,
@AuthenticationPrincipal currentUser: CurrentUser,
): ClassroomDetailResponse =
classroomService.getClassroomById(id, currentUser.subject).let { classroom ->
val pupilIds = classroom.pupilMemberships.mapNotNull { membership -> membership.pupil?.id }
val pupilDisplayNames =
pupilProfileRepository
.findAllByUserIdIn(pupilIds)
.associate { profile -> requireNotNull(profile.userId) to profile.displayName }

classroom.toDetailResponse(pupilDisplayNames)
classroomService.getClassroomById(id, currentUser.subject).let { detail ->
detail.classroom.toDetailResponse(detail.pupilDisplayNames)
}

/**
* Endpoint for updating classroom information.
*
* @param id the unique id of the classroom to be updated.
* @param request the update classroom request containing the updated classroom data.
* @param currentUser the updated classroom.
* @param currentUser the authenticated user extracted from the security context.
* @return updated classroom.
*/
@PutMapping("/{id}")
fun updateClassroom(
Expand All @@ -129,7 +135,7 @@ class ClassroomController(
*
* @param classroomId the id of the classroom the pupil should be removed from.
* @param pupilId the id of the student being removed.
* @param currentUser the authenticates user extracted from the security context.
* @param currentUser the authenticated user extracted from the security context.
*/
@DeleteMapping("/{classroomId}/pupils/{pupilId}")
fun removePupil(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package edu.ntnu.idi.idatt.backend.classroom.application

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

/**
* Classroom detail model enriched with presentation data owned by other domains.
*
* @property classroom classroom entity after access checks have passed.
* @property pupilDisplayNames display names keyed by pupil user id.
*/
data class ClassroomDetail(
val classroom: Classroom,
val pupilDisplayNames: Map<Long, String>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package edu.ntnu.idi.idatt.backend.classroom.application

import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository
import org.springframework.stereotype.Service

/**
* Read-oriented classroom operations exposed to other domains.
*
* @property classroomRepository repository that owns classroom persistence.
*/
@Service
class ClassroomQueryService(
private val classroomRepository: ClassroomRepository,
) {
/**
* Finds one classroom by persistent id.
*
* @param classroomId classroom id to look up.
* @return matching classroom, or `null` when no classroom exists.
*/
fun findById(classroomId: Long): Classroom? = classroomRepository.findById(classroomId).orElse(null)

/**
* Finds the first classroom owned by the teacher, ordered by classroom id.
*
* @param teacherUserId teacher user id that owns the classroom.
* @return first owned classroom, or `null` when the teacher owns none.
*/
fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId)

/**
* Returns every classroom in persistence order.
*
* @return all classrooms.
*/
fun findAll(): List<Classroom> = classroomRepository.findAll()

/**
* Resolves the teacher recipients for one classroom notification.
*
* The returned ids include the owner and any teacher memberships, with duplicates removed.
*
* @param classroomId classroom whose teachers should receive a notification.
* @return teacher user ids, or `null` when the classroom does not exist.
*/
fun findTeacherRecipientIds(classroomId: Long): Set<Long>? =
findById(classroomId)?.let { classroom ->
buildSet {
add(classroom.ownerTeacherId)
classroom.teacherMemberships
.mapNotNull { membership -> membership.teacher?.id }
.forEach(::add)
}
}

/**
* Returns classroom average score projections for leaderboard rendering.
*
* @return classroom score projections ordered by the repository query.
*/
fun findAverageTotalScores(): List<ClassroomAverageScore> =
classroomRepository.findAllWithAverageTotalScore().map { score ->
ClassroomAverageScore(
classroomId = score.classroomId,
classroomTitle = score.classroomTitle,
averageTotalScore = score.averageTotalScore,
)
}
}

/**
* Classroom aggregate score projection used outside the classroom domain.
*
* @property classroomId classroom id.
* @property classroomTitle display title for the classroom.
* @property averageTotalScore average total score across pupils in the classroom.
*/
data class ClassroomAverageScore(
val classroomId: Long,
val classroomTitle: String,
val averageTotalScore: Double,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ 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
import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService
import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository
import edu.ntnu.idi.idatt.backend.game.tasks.application.TaskQueryService
import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver
import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver
import edu.ntnu.idi.idatt.backend.iam.application.IamUserService
import edu.ntnu.idi.idatt.backend.iam.domain.UserRole
import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository
import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher
import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository
import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -24,21 +24,21 @@ import java.util.UUID
*
* @property classroomRepository repository for classroom access.
* @property currentUserResolver shared resolver for the authenticated user and role guard.
* @property userRepository repository for user access.
* @property iamUserService service for user access.
* @property classroomUserResolver resolver for classroom access control.
* @property pupilGameProgressService service for classroom-scoped game progress entries.
* @property taskRepository repository used to count classroom tasks.
* @property taskQueryService service used to count classroom tasks.
* @property domainEventPublisher publisher for application events.
*/
@Service
class ClassroomService(
private val classroomRepository: ClassroomRepository,
private val currentUserResolver: CurrentUserResolver,
private val userRepository: UserRepository,
private val iamUserService: IamUserService,
private val classroomUserResolver: ClassroomUserResolver,
private val pupilGameProgressService: PupilGameProgressService,
private val taskRepository: TaskRepository,
private val pupilProfileRepository: PupilProfileRepository,
private val taskQueryService: TaskQueryService,
private val userProfileService: UserProfileService,
private val domainEventPublisher: DomainEventPublisher,
) {
/**
Expand Down Expand Up @@ -129,12 +129,11 @@ class ClassroomService(
}

classroom.addPupilMembership(user)
val profile =
pupilProfileRepository
.findById(userId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found") }
profile.currentClassroom = classroom
pupilProfileRepository.save(profile)
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 @@ -232,7 +231,7 @@ class ClassroomService(

val emails = command.teacherEmails.map { it.trim().lowercase(Locale.ROOT) }

val teachers = userRepository.findAllByEmailIgnoreCaseIn(emails)
val teachers = iamUserService.findTeachersByEmails(emails)

if (teachers.size != emails.size) {
val foundEmails = teachers.mapNotNull { it.email?.lowercase(Locale.ROOT) }
Expand Down Expand Up @@ -261,15 +260,19 @@ class ClassroomService(
fun getClassroomById(
classroomId: Long,
authenticatedSubject: String,
): Classroom {
): ClassroomDetail {
val user = currentUserResolver.requireUser(authenticatedSubject)
classroomUserResolver.requireClassroomMember(user, classroomId)

val classroom =
classroomRepository.findById(classroomId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")

return classroom
val pupilIds = classroom.pupilMemberships.mapNotNull { membership -> membership.pupil?.id }
return ClassroomDetail(
classroom = classroom,
pupilDisplayNames = userProfileService.findPupilDisplayNames(pupilIds),
)
}

/**
Expand Down Expand Up @@ -343,6 +346,6 @@ class ClassroomService(
pupilCount = classroom.pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE },
pendingPupilCount = classroom.pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING },
teacherCount = 1,
taskCount = taskRepository.countByClassroomId(classroom.id).toInt(),
taskCount = taskQueryService.countTasksInClassroom(classroom.id),
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package edu.ntnu.idi.idatt.backend.game.application

import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService
import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository
import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver
import edu.ntnu.idi.idatt.backend.iam.domain.User
import edu.ntnu.idi.idatt.backend.iam.domain.UserRole
Expand All @@ -24,7 +24,7 @@ enum class GameReadMode {
@Service
class GameAccessContextResolver(
private val currentUserResolver: CurrentUserResolver,
private val classroomRepository: ClassroomRepository,
private val classroomQueryService: ClassroomQueryService,
) {
/**
* Resolves the caller's effective read capabilities for one classroom.
Expand All @@ -40,11 +40,8 @@ class GameAccessContextResolver(
): GameAccessContext {
val user = currentUserResolver.requireUser(currentUser.subject)
val classroom =
classroomRepository
.findById(classroomId)
.orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")
}
classroomQueryService.findById(classroomId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")
val userId = requireNotNull(user.id) { "Authenticated user is missing an id" }

if (readMode == GameReadMode.TEACHER_PLAYTEST) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package edu.ntnu.idi.idatt.backend.game.gameProgress.application

import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository
import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService
import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService.Companion.XP_PER_LEVEL
import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress
import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgressId
import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository
import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver
import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository
import edu.ntnu.idi.idatt.backend.iam.application.IamUserService
import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
Expand All @@ -22,15 +22,15 @@ import java.time.Instant
* flows, and profile/notebook lookups that need progress-derived values.
*
* @property pupilGameProgressRepository repository for persisted pupil game-progress rows
* @property userRepository repository used to resolve pupils when creating new progress rows
* @property classroomRepository repository used to resolve classrooms when creating new progress rows
* @property iamUserService service used to resolve pupils when creating new progress rows
* @property classroomQueryService service used to resolve classrooms when creating new progress rows
* @property currentUserResolver resolver used to derive the active pupil and classroom from the authenticated user
*/
@Service
class PupilGameProgressService(
private val pupilGameProgressRepository: PupilGameProgressRepository,
private val userRepository: UserRepository,
private val classroomRepository: ClassroomRepository,
private val iamUserService: IamUserService,
private val classroomQueryService: ClassroomQueryService,
private val currentUserResolver: CurrentUserResolver,
) {
companion object {
Expand Down Expand Up @@ -117,13 +117,11 @@ class PupilGameProgressService(
}

val pupil =
userRepository.findById(pupilId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found")
}
iamUserService.findById(pupilId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found")
val classroom =
classroomRepository.findById(classroomId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")
}
classroomQueryService.findById(classroomId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")

pupilGameProgressRepository.save(
PupilGameProgress(
Expand Down
Loading