From 6f1f7f90d02e87d5080682d30e2a4b7ea36ed438 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Thu, 30 Apr 2026 11:22:14 +0200 Subject: [PATCH] feat: teacher overview classroom and status (#79) * Add teacher email handling in classroom creation * Update tests * Add status and ownerTeacherId to classroom models and update handling * Format test --- .../classroom/api/ClassroomApiMappings.kt | 34 ++++++++++++----- .../classroom/api/ClassroomDetailResponse.kt | 4 ++ .../classroom/api/ClassroomResponse.kt | 8 +++- .../classroom/api/ClassroomTeacherResponse.kt | 2 + .../classroom/api/CreateClassroomRequest.kt | 2 + .../classroom/api/UpdateClassroomRequest.kt | 2 + .../classroom/application/ClassroomService.kt | 37 ++++++++++++++++++- .../application/CreateClassroomCommand.kt | 2 + .../application/UpdateClassroomCommand.kt | 2 + .../classroom/ClassroomIntegrationTests.kt | 12 +++++- .../backend/classroom/ClassroomServiceTest.kt | 15 +++++--- 11 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt index 5392e25e..cb58fe5e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt @@ -6,6 +6,7 @@ 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.iam.domain.User /** * Converts the join classroom request to the application command. @@ -27,6 +28,7 @@ fun CreateClassroomRequest.toCommand(subject: String): CreateClassroomCommand = CreateClassroomCommand( title = title, description = description, + teacherEmails = teacherEmails, authenticatedSubject = subject, ) @@ -44,6 +46,7 @@ fun UpdateClassroomRequest.toCommand( title = title, description = description, teacherEmails = teacherEmails, + status = status, authenticatedSubject = authenticatedSubject, ) @@ -71,6 +74,9 @@ fun Classroom.toResponse(): ClassroomResponse = title = title, description = description, joinCode = joinCode ?: "", + status = status.name, + ownerTeacherId = ownerTeacherId, + teachers = teacherResponses(), teacherUserIds = teacherMemberships.mapNotNull { it.teacher?.id }, ) @@ -88,15 +94,9 @@ fun Classroom.toDetailResponse( title = title, description = description, joinCode = joinCode ?: "", - teachers = - teacherMemberships.mapNotNull { membership -> - val teacher = membership.teacher ?: return@mapNotNull null - - ClassroomTeacherResponse( - userId = teacher.id ?: return@mapNotNull null, - email = teacher.email, - ) - }, + status = status.name, + ownerTeacherId = ownerTeacherId, + teachers = teacherResponses(), pupils = pupilMemberships.mapNotNull { membership -> val pupil = membership.pupil ?: return@mapNotNull null @@ -112,6 +112,22 @@ fun Classroom.toDetailResponse( }, ) +private fun Classroom.teacherResponses(): List = + teacherMemberships.mapNotNull { membership -> + val teacher = membership.teacher ?: return@mapNotNull null + teacher.toClassroomTeacherResponse(ownerTeacherId) + } + +private fun User.toClassroomTeacherResponse(ownerTeacherId: Long): ClassroomTeacherResponse? { + val userId = id ?: return null + + return ClassroomTeacherResponse( + userId = userId, + email = email, + isOwner = userId == ownerTeacherId, + ) +} + fun OwnedClassroomSummary.toOwnedClassroomResponse(): OwnedClassroomResponse = OwnedClassroomResponse( id = id, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt index 0aa37e2d..8c0fc01e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt @@ -7,6 +7,8 @@ package edu.ntnu.idi.idatt.backend.classroom.api * @param title the title of the classroom. * @param description the description of the classroom. * @param joinCode the code for joining the classroom. + * @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 the teachers that are members of the classroom. * @param pupils a list of the pupils that are members of the classroom. */ @@ -15,6 +17,8 @@ data class ClassroomDetailResponse( val title: String, val description: String?, val joinCode: String, + val status: String, + val ownerTeacherId: Long, val teachers: List, val pupils: List, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt index b2f2d0f8..ab163e5a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt @@ -7,12 +7,18 @@ 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 teacherUserIds a list of ids of teahcers belonging to the classroom. + * @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. + * @param teacherUserIds a list of ids of teachers belonging to the classroom. */ data class ClassroomResponse( val classroomId: Long, val title: String, val description: String?, val joinCode: String, + val status: String, + val ownerTeacherId: Long, + val teachers: List, val teacherUserIds: List, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt index 34bd1e3c..ccacce40 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt @@ -5,8 +5,10 @@ package edu.ntnu.idi.idatt.backend.classroom.api * * @param userId the id of a teacher in the classroom. * @param email the email address of a teacher in the classroom. + * @param isOwner whether this teacher owns the classroom. */ data class ClassroomTeacherResponse( val userId: Long, val email: String?, + val isOwner: Boolean, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt index 3cddb666..9aed643f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt @@ -8,10 +8,12 @@ import jakarta.validation.constraints.Size * * @property title the title of the classroom to be created. Must not be blank. * @property description the optional description of the classroom. + * @property teacherEmails a list of emails of teachers belonging to the classroom. */ data class CreateClassroomRequest( @field:NotBlank @field:Size(max = 100) val title: String, val description: String? = null, + val teacherEmails: List = emptyList(), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt index 09bd1a89..fd93536a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt @@ -8,10 +8,12 @@ import jakarta.validation.constraints.NotBlank * @property title the title of the classroom. Must not be blank. * @property description the description of the classroom. * @property teacherEmails a list of emails of teachers belonging to the classroom. + * @property status whether the classroom should be active or inactive. */ data class UpdateClassroomRequest( @field:NotBlank val title: String, val description: String? = null, val teacherEmails: List = emptyList(), + val status: String? = null, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt index b69cd230..72a080eb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt @@ -52,6 +52,21 @@ class ClassroomService( fun createClassroom(command: CreateClassroomCommand): Classroom { val teacher = currentUserResolver.requireTeacherOrAdmin(command.authenticatedSubject) val joinCode = generateJoinCode() + val currentTeacherEmail = teacher.email?.trim()?.lowercase(Locale.ROOT) + val emails = + command.teacherEmails + .map { it.trim().lowercase(Locale.ROOT) } + .filter { it.isNotBlank() } + .filter { it != currentTeacherEmail } + .distinct() + + val teachers = iamUserService.findTeachersByEmails(emails) + + if (teachers.size != emails.size) { + val foundEmails = teachers.mapNotNull { it.email?.lowercase(Locale.ROOT) } + val missing = emails - foundEmails.toSet() + throw ResponseStatusException(HttpStatus.CONFLICT, "Teachers not found: $missing") + } val classroom = Classroom( @@ -63,6 +78,7 @@ class ClassroomService( ) classroom.addTeacher(teacher) + teachers.forEach(classroom::addTeacher) return classroomRepository.save(classroom) } @@ -228,8 +244,24 @@ class ClassroomService( classroom.title = command.title classroom.description = command.description - - val emails = command.teacherEmails.map { it.trim().lowercase(Locale.ROOT) } + classroom.status = + command.status?.let { status -> + runCatching { ClassroomStatus.valueOf(status.trim().uppercase(Locale.ROOT)) } + .getOrElse { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid classroom status: $status") + } + } ?: classroom.status + + val owner = + iamUserService.findById(classroom.ownerTeacherId) + ?: throw ResponseStatusException(HttpStatus.CONFLICT, "Classroom owner not found") + val ownerEmail = owner.email?.trim()?.lowercase(Locale.ROOT) + val emails = + command.teacherEmails + .map { it.trim().lowercase(Locale.ROOT) } + .filter { it.isNotBlank() } + .filter { it != ownerEmail } + .distinct() val teachers = iamUserService.findTeachersByEmails(emails) @@ -241,6 +273,7 @@ class ClassroomService( classroom.teacherMemberships.clear() + classroom.addTeacher(owner) teachers.forEach { teacher -> classroom.addTeacher(teacher) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt index 06d9ce58..f683656b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt @@ -7,10 +7,12 @@ package edu.ntnu.idi.idatt.backend.classroom.application * * @property title the title of the classroom. * @property description the optional description of the classroom. + * @property teacherEmails the list of teachers to add to the classroom. * @property authenticatedSubject JWT subject for the authenticated subject user attempting to create the classroom. */ data class CreateClassroomCommand( val title: String, val description: String?, + val teacherEmails: List, val authenticatedSubject: String, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt index 5b1adc83..b5f0ed14 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt @@ -9,6 +9,7 @@ package edu.ntnu.idi.idatt.backend.classroom.application * @property title the updated title of the classroom. * @property description the updated description of the classroom. * @property teacherEmails the updated list of emails of teachers members of the classroom. + * @property status the updated classroom status. * @property authenticatedSubject JWT subject for the authenticated subject user attempting to update the classroom. */ data class UpdateClassroomCommand( @@ -16,5 +17,6 @@ data class UpdateClassroomCommand( val title: String, val description: String?, val teacherEmails: List, + val status: String?, val authenticatedSubject: String, ) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt index a96701ab..cc0c611a 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt @@ -16,6 +16,7 @@ import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarReposito import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import org.hamcrest.Matchers.hasItem import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -346,6 +347,9 @@ class ClassroomIntegrationTests { status { isOk() } jsonPath("$.title") { value("Test Classroom") } jsonPath("$.description") { value("Test Description") } + jsonPath("$.status") { value("ACTIVE") } + jsonPath("$.teachers[*].email") { value(hasItem("teacher-create@test.com")) } + jsonPath("$.teacherUserIds[0]") { exists() } } } @@ -355,13 +359,15 @@ class ClassroomIntegrationTests { val ownerEmail = "owner-UPDATE1@example.com" val token = teacherPortalAccessToken(ownerEmail, "secret123") + userRepository.save(activeTeacher("teacher-update@test.com")) val request = """ { "title": "Updated Classroom", "description": "Updated Classroom Description", - "teacherEmails": [] + "teacherEmails": ["teacher-update@test.com"], + "status": "INACTIVE" } """.trimIndent() @@ -375,6 +381,10 @@ class ClassroomIntegrationTests { status { isOk() } jsonPath("$.title") { value("Updated Classroom") } jsonPath("$.description") { value("Updated Classroom Description") } + jsonPath("$.status") { value("INACTIVE") } + jsonPath("$.teachers[*].email") { value(hasItem(ownerEmail.lowercase())) } + jsonPath("$.teachers[*].email") { value(hasItem("teacher-update@test.com")) } + jsonPath("$.teacherUserIds[0]") { exists() } } } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt index d9f4c815..ae9bb8c2 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt @@ -86,6 +86,7 @@ class ClassroomServiceTest { CreateClassroomCommand( title = "Math", description = "description", + teacherEmails = emptyList(), authenticatedSubject = "1", ), ) @@ -342,14 +343,17 @@ class ClassroomServiceTest { @Test fun `should update classroom title, description and teachers`() { - val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "old", description = "old", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) + val classroom = Classroom(id = 1, ownerTeacherId = 10, title = "old", description = "old", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) val teacher = User(id = 10, email = "teacher@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) + `when`(currentUserResolver.requireTeacherOrAdmin("10")).thenReturn(teacher) + `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenReturn(classroom) `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) - `when`(userRepository.findAllByEmailIgnoreCaseIn(listOf("teacher@test.com"))).thenReturn(listOf(teacher)) + `when`(userRepository.findById(10)).thenReturn(Optional.of(teacher)) + `when`(userRepository.findAllByEmailIgnoreCaseIn(emptyList())).thenReturn(emptyList()) `when`(classroomRepository.save(any())).thenAnswer { it.arguments[0] } - val command = UpdateClassroomCommand(classroomId = 1, title = "new", description = "new", teacherEmails = listOf("teacher@test.com"), authenticatedSubject = "10") + val command = UpdateClassroomCommand(classroomId = 1, title = "new", description = "new", teacherEmails = listOf("teacher@test.com"), status = null, authenticatedSubject = "10") val result = classroomService.updateClassroom(command) @@ -366,12 +370,13 @@ class ClassroomServiceTest { val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenAnswer { } + `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenReturn(classroom) `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) + `when`(userRepository.findById(2)).thenReturn(Optional.of(teacher)) `when`(userRepository.findAllByEmailIgnoreCaseIn(listOf("missing@test.com"))).thenReturn(emptyList()) - val command = UpdateClassroomCommand(classroomId = 1, title = "new", description = "new", teacherEmails = listOf("missing@test.com"), authenticatedSubject = "2") + val command = UpdateClassroomCommand(classroomId = 1, title = "new", description = "new", teacherEmails = listOf("missing@test.com"), status = null, authenticatedSubject = "2") val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) {