From af550a2bdd2d9cb2e2e611b186881404a3f27082 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Tue, 28 Apr 2026 13:56:38 +0200 Subject: [PATCH 1/5] add backend for banned words --- .../moderation/api/ClassroomBannedWordApi.kt | 49 +++++++ .../api/ClassroomModerationController.kt | 65 +++++++++ .../ClassroomModerationCommands.kt | 12 ++ .../application/ClassroomModerationService.kt | 135 ++++++++++++++++++ .../moderation/domain/ClassroomBannedWord.kt | 51 +++++++ .../ClassroomBannedWordRepository.kt | 20 +++ .../mystery/application/MysteryService.kt | 4 + .../notebook/application/NotebookService.kt | 10 ++ ...5__create_classroom_banned_words_table.sql | 18 +++ 9 files changed, 364 insertions(+) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt create mode 100644 src/main/resources/db/migration/V25__create_classroom_banned_words_table.sql diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt new file mode 100644 index 00000000..6eb6ee5d --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt @@ -0,0 +1,49 @@ +package edu.ntnu.idi.idatt.backend.moderation.api + +import edu.ntnu.idi.idatt.backend.moderation.application.CreateClassroomBannedWordCommand +import edu.ntnu.idi.idatt.backend.moderation.application.UpdateClassroomBannedWordCommand +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.time.Instant + +data class ClassroomBannedWordRequest( + @field:NotBlank + @field:Size(max = 100) + val word: String, +) + +data class ClassroomBannedWordResponse( + val id: Long, + val classroomId: Long, + val word: String, + val createdByTeacherUserId: Long, + val createdAt: Instant?, + val updatedAt: Instant?, +) + +fun ClassroomBannedWordRequest.toCreateCommand(classroomId: Long): CreateClassroomBannedWordCommand = + CreateClassroomBannedWordCommand( + classroomId = classroomId, + word = word, + ) + +fun ClassroomBannedWordRequest.toUpdateCommand( + classroomId: Long, + bannedWordId: Long, +): UpdateClassroomBannedWordCommand = + UpdateClassroomBannedWordCommand( + classroomId = classroomId, + bannedWordId = bannedWordId, + word = word, + ) + +fun ClassroomBannedWord.toResponse(): ClassroomBannedWordResponse = + ClassroomBannedWordResponse( + id = requireNotNull(id) { "Banned word is missing an id" }, + classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" }, + word = word, + createdByTeacherUserId = createdByTeacherUserId, + createdAt = createdAt, + updatedAt = updatedAt, + ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt new file mode 100644 index 00000000..168c2fa9 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt @@ -0,0 +1,65 @@ +package edu.ntnu.idi.idatt.backend.moderation.api + +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/teacher/classrooms/{classroomId}/moderation/banned-words") +@PreAuthorize("hasAnyRole('TEACHER', 'ADMIN')") +class ClassroomModerationController( + private val classroomModerationService: ClassroomModerationService, +) { + @GetMapping + fun listBannedWords( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List = + classroomModerationService + .listBannedWords(currentUser.subject, classroomId) + .map { it.toResponse() } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun createBannedWord( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @Valid @RequestBody request: ClassroomBannedWordRequest, + ): ClassroomBannedWordResponse = + classroomModerationService + .createBannedWord(currentUser.subject, request.toCreateCommand(classroomId)) + .toResponse() + + @PutMapping("/{bannedWordId}") + fun updateBannedWord( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @PathVariable bannedWordId: Long, + @Valid @RequestBody request: ClassroomBannedWordRequest, + ): ClassroomBannedWordResponse = + classroomModerationService + .updateBannedWord(currentUser.subject, request.toUpdateCommand(classroomId, bannedWordId)) + .toResponse() + + @DeleteMapping("/{bannedWordId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteBannedWord( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @PathVariable bannedWordId: Long, + ) { + classroomModerationService.deleteBannedWord(currentUser.subject, classroomId, bannedWordId) + } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt new file mode 100644 index 00000000..06b74df7 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt @@ -0,0 +1,12 @@ +package edu.ntnu.idi.idatt.backend.moderation.application + +data class CreateClassroomBannedWordCommand( + val classroomId: Long, + val word: String, +) + +data class UpdateClassroomBannedWordCommand( + val classroomId: Long, + val bannedWordId: Long, + val word: String, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt new file mode 100644 index 00000000..b8efa008 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt @@ -0,0 +1,135 @@ +package edu.ntnu.idi.idatt.backend.moderation.application + +import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.util.Locale + +@Service +class ClassroomModerationService( + private val currentUserResolver: CurrentUserResolver, + private val classroomUserResolver: ClassroomUserResolver, + private val classroomBannedWordRepository: ClassroomBannedWordRepository, +) { + @Transactional(readOnly = true) + fun listBannedWords( + authenticatedSubject: String, + classroomId: Long, + ): List { + val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + + return classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + } + + @Transactional + fun createBannedWord( + authenticatedSubject: String, + command: CreateClassroomBannedWordCommand, + ): ClassroomBannedWord { + val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) + val classroom = classroomUserResolver.requireClassroomTeacherAccess(teacher, command.classroomId) + val normalizedWord = normalizeWord(command.word) + + if (classroomBannedWordRepository.existsByClassroomIdAndWord(command.classroomId, normalizedWord)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Banned word already exists for this classroom") + } + + return classroomBannedWordRepository.save( + ClassroomBannedWord( + classroom = classroom, + createdByTeacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + word = normalizedWord, + ), + ) + } + + @Transactional + fun updateBannedWord( + authenticatedSubject: String, + command: UpdateClassroomBannedWordCommand, + ): ClassroomBannedWord { + val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, command.classroomId) + + val bannedWord = + classroomBannedWordRepository.findByIdAndClassroomId(command.bannedWordId, command.classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Banned word not found") + + val normalizedWord = normalizeWord(command.word) + if (normalizedWord != bannedWord.word && + classroomBannedWordRepository.existsByClassroomIdAndWord(command.classroomId, normalizedWord) + ) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Banned word already exists for this classroom") + } + + bannedWord.word = normalizedWord + return classroomBannedWordRepository.save(bannedWord) + } + + @Transactional + fun deleteBannedWord( + authenticatedSubject: String, + classroomId: Long, + bannedWordId: Long, + ) { + val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + + val bannedWord = + classroomBannedWordRepository.findByIdAndClassroomId(bannedWordId, classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Banned word not found") + + classroomBannedWordRepository.delete(bannedWord) + } + + @Transactional(readOnly = true) + fun assertNoBannedWords( + classroomId: Long, + vararg texts: String, + ) { + val configuredWords = classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + if (configuredWords.isEmpty()) { + return + } + + val blocked = + configuredWords.firstOrNull { bannedWord -> + texts.any { text -> containsConfiguredWord(text, bannedWord.word) } + } + + if (blocked != null) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Text contains banned words") + } + } + + private fun normalizeWord(word: String): String { + val normalized = word.trim().lowercase(Locale.ROOT) + if (normalized.isBlank()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Banned word must not be blank") + } + return normalized + } + + private fun containsConfiguredWord( + text: String, + configuredWord: String, + ): Boolean { + if (text.isBlank()) { + return false + } + + val pattern = + Regex( + pattern = "(^|[^\\p{L}\\p{N}_])${Regex.escape(configuredWord)}([^\\p{L}\\p{N}_]|$)", + options = setOf(RegexOption.IGNORE_CASE), + ) + + return pattern.containsMatchIn(text) + } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt new file mode 100644 index 00000000..329c0720 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt @@ -0,0 +1,51 @@ +package edu.ntnu.idi.idatt.backend.moderation.domain + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.Instant +import java.util.Locale + +/** + * Classroom-scoped teacher-managed banned word or phrase. + * + * The stored [word] is normalized to trimmed lowercase text so duplicate checks and moderation + * rules stay deterministic regardless of user input casing. + */ +@Entity +@Table(name = "classroom_banned_words") +class ClassroomBannedWord( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + var classroom: Classroom, + @Column(name = "created_by_teacher_user_id", nullable = false) + var createdByTeacherUserId: Long, + @Column(name = "word", nullable = false, length = 100) + var word: String, + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant? = null, + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant? = null, +) { + @PrePersist + @PreUpdate + fun normalizeWord() { + word = word.trim().lowercase(Locale.ROOT) + } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt new file mode 100644 index 00000000..23fc4f45 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt @@ -0,0 +1,20 @@ +package edu.ntnu.idi.idatt.backend.moderation.infrastructure + +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ClassroomBannedWordRepository : JpaRepository { + fun existsByClassroomIdAndWord( + classroomId: Long, + word: String, + ): Boolean + + fun findAllByClassroomIdOrderByWordAsc(classroomId: Long): List + + fun findByIdAndClassroomId( + id: Long, + classroomId: Long, + ): ClassroomBannedWord? +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt index 04c32368..14508890 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt @@ -1,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.mystery.application import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery @@ -35,6 +36,7 @@ class MysteryService( private val weeklyMysteryRepository: WeeklyMysteryRepository, private val userRepository: UserRepository, private val domainEventPublisher: DomainEventPublisher, + private val classroomModerationService: ClassroomModerationService, ) { /** * Creates a new mystery submission for a pupil and notifies teachers via a domain event. @@ -45,6 +47,8 @@ class MysteryService( */ @Transactional fun submitExample(command: SubmitMysteryCommand): MysterySubmission { + classroomModerationService.assertNoBannedWords(command.classroomId, command.description) + val pupil = userRepository.findById(command.pupilUserId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt index 3f7cc8e9..ebecdb89 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt @@ -5,6 +5,7 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepositor 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.security.CurrentUser +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookEntry import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookSourceType import edu.ntnu.idi.idatt.backend.notebook.infrastructure.NotebookEntryRepository @@ -32,6 +33,7 @@ class NotebookService( private val mapStopsRepository: MapStopsRepository, private val pupilGameProgressService: PupilGameProgressService, private val xpService: XpService, + private val classroomModerationService: ClassroomModerationService, ) { /** * Returns all notebook entries owned by the authenticated pupil. @@ -103,6 +105,8 @@ class NotebookService( ) } + classroomModerationService.assertNoBannedWords(command.classroomId, command.title, command.content) + val stop = mapStopsRepository .findById(command.stopId) @@ -135,6 +139,12 @@ class NotebookService( val pupil = currentUserResolver.requirePupil(command.currentUser.subject) val notebookEntry = verifyOwnership(pupil.id, command.notebookEntryId) + classroomModerationService.assertNoBannedWords( + requireNotNull(notebookEntry.classroom.id) { "Notebook classroom is missing an id" }, + command.title, + command.content, + ) + notebookEntry.title = command.title notebookEntry.content = command.content diff --git a/src/main/resources/db/migration/V25__create_classroom_banned_words_table.sql b/src/main/resources/db/migration/V25__create_classroom_banned_words_table.sql new file mode 100644 index 00000000..1e8a0217 --- /dev/null +++ b/src/main/resources/db/migration/V25__create_classroom_banned_words_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE classroom_banned_words ( + id BIGINT NOT NULL AUTO_INCREMENT, + classroom_id BIGINT NOT NULL, + created_by_teacher_user_id BIGINT NOT NULL, + word VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + CONSTRAINT pk_classroom_banned_words PRIMARY KEY (id), + CONSTRAINT fk_classroom_banned_words_classroom + FOREIGN KEY (classroom_id) REFERENCES classrooms (id) ON DELETE CASCADE, + CONSTRAINT fk_classroom_banned_words_teacher + FOREIGN KEY (created_by_teacher_user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT uk_classroom_banned_words_classroom_word + UNIQUE (classroom_id, word) +); + +CREATE INDEX idx_classroom_banned_words_classroom + ON classroom_banned_words (classroom_id); From d0dfa288e748829dd1c140ebe0506db64439b05e Mon Sep 17 00:00:00 2001 From: eilifhl Date: Tue, 28 Apr 2026 13:56:54 +0200 Subject: [PATCH 2/5] tests --- ...oomModerationControllerIntegrationTests.kt | 189 ++++++++++++++++++ .../MysteryControllerIntegrationTests.kt | 26 +++ .../backend/mystery/MysteryServiceTests.kt | 4 + ...NotebookEntryControllerIntegrationTests.kt | 48 +++++ .../backend/notebook/NotebookServiceTests.kt | 27 +++ 5 files changed, 294 insertions(+) create mode 100644 src/test/kotlin/edu/ntnu/idi/idatt/backend/moderation/ClassroomModerationControllerIntegrationTests.kt diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/moderation/ClassroomModerationControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/moderation/ClassroomModerationControllerIntegrationTests.kt new file mode 100644 index 00000000..706e30c6 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/moderation/ClassroomModerationControllerIntegrationTests.kt @@ -0,0 +1,189 @@ +package edu.ntnu.idi.idatt.backend.moderation + +import com.fasterxml.jackson.databind.json.JsonMapper +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put + +@SpringBootTest +@AutoConfigureMockMvc +class ClassroomModerationControllerIntegrationTests { + @Autowired private lateinit var mockMvc: MockMvc + + @Autowired private lateinit var userRepository: UserRepository + + @Autowired private lateinit var classroomRepository: ClassroomRepository + + @Autowired private lateinit var classroomBannedWordRepository: ClassroomBannedWordRepository + + @Autowired private lateinit var refreshTokenRepository: RefreshTokenRepository + + @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository + + @Autowired private lateinit var teacherProfileRepository: TeacherProfileRepository + + @Autowired private lateinit var passwordEncoder: PasswordEncoder + + private val jsonMapper = JsonMapper.builder().build() + + @BeforeEach + fun setUp() { + classroomBannedWordRepository.deleteAll() + refreshTokenRepository.deleteAll() + classroomRepository.deleteAll() + userAvatarRepository.deleteAll() + pupilProfileRepository.deleteAll() + teacherProfileRepository.deleteAll() + userRepository.deleteAll() + } + + @Test + fun `teacher can create list update and delete classroom banned words`() { + val teacher = userRepository.save(activeTeacher("teacher-moderation@test.com")) + val classroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Moderation Class", + description = "desc", + joinCode = "MOD01", + status = ClassroomStatus.ACTIVE, + ), + ) + classroom.addTeacher(teacher) + classroomRepository.save(classroom) + val teacherToken = teacherToken("teacher-moderation@test.com") + + val createdBody = + jsonMapper.readTree( + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + contentType = MediaType.APPLICATION_JSON + content = """{"word":"Clue"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.word") { value("clue") } + }.andReturn() + .response.contentAsString, + ) + + val bannedWordId = createdBody.get("id").asLong() + + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words") { + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].word") { value("clue") } + } + + mockMvc + .put("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words/$bannedWordId") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + contentType = MediaType.APPLICATION_JSON + content = """{"word":"spoiler"}""" + }.andExpect { + status { isOk() } + jsonPath("$.word") { value("spoiler") } + } + + mockMvc + .delete("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words/$bannedWordId") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + }.andExpect { + status { isNoContent() } + } + + assertEquals(0, classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroom.id).size) + } + + @Test + fun `teacher cannot create duplicate banned word for classroom`() { + val teacher = userRepository.save(activeTeacher("teacher-duplicate@test.com")) + val classroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Moderation Class", + description = "desc", + joinCode = "MOD02", + status = ClassroomStatus.ACTIVE, + ), + ) + val teacherToken = teacherToken("teacher-duplicate@test.com") + + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + contentType = MediaType.APPLICATION_JSON + content = """{"word":"clue"}""" + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/moderation/banned-words") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + contentType = MediaType.APPLICATION_JSON + content = """{"word":"Clue"}""" + }.andExpect { + status { isConflict() } + } + } + + private fun teacherToken(email: String): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() + + private fun activeTeacher(email: String): User = + User( + email = email, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt index 9d2c61a6..fb42b90b 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt @@ -9,6 +9,8 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository @@ -54,6 +56,8 @@ class MysteryControllerIntegrationTests { @Autowired private lateinit var passwordEncoder: PasswordEncoder + @Autowired private lateinit var classroomBannedWordRepository: ClassroomBannedWordRepository + private val jsonMapper = JsonMapper.builder().build() private lateinit var classroom: Classroom @@ -64,6 +68,7 @@ class MysteryControllerIntegrationTests { fun setUp() { weeklyMysteryRepository.deleteAll() submissionRepository.deleteAll() + classroomBannedWordRepository.deleteAll() refreshTokenRepository.deleteAll() classroomRepository.deleteAll() userAvatarRepository.deleteAll() @@ -172,6 +177,27 @@ class MysteryControllerIntegrationTests { assertEquals(1, submissionRepository.findAllByClassroomId(classroom.id).size) } + @Test + fun `POST submissions rejects configured banned word`() { + classroomBannedWordRepository.save( + ClassroomBannedWord( + classroom = classroom, + createdByTeacherUserId = requireNotNull(teacher.id), + word = "fake news", + ), + ) + + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"I found fake news online","attachmentUrl":null}""" + }.andExpect { + status { isUnprocessableEntity() } + } + } + @Test fun `POST submissions requires authentication`() { mockMvc diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt index 6c1c85cb..7ec83358 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt @@ -6,6 +6,7 @@ import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService import edu.ntnu.idi.idatt.backend.mystery.application.FeatureMysteryCommand import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService.Companion.currentWeekStart @@ -42,6 +43,7 @@ class MysteryServiceTests { private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository private lateinit var userRepository: UserRepository private lateinit var domainEventPublisher: DomainEventPublisher + private lateinit var classroomModerationService: ClassroomModerationService private lateinit var mysteryService: MysteryService private val classroom = @@ -78,12 +80,14 @@ class MysteryServiceTests { weeklyMysteryRepository = mock(WeeklyMysteryRepository::class.java) userRepository = mock(UserRepository::class.java) domainEventPublisher = mock(DomainEventPublisher::class.java) + classroomModerationService = mock(ClassroomModerationService::class.java) mysteryService = MysteryService( submissionRepository, weeklyMysteryRepository, userRepository, domainEventPublisher, + classroomModerationService, ) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt index 659431e4..e6472da4 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt @@ -11,6 +11,8 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookEntry import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookSourceType import edu.ntnu.idi.idatt.backend.notebook.infrastructure.NotebookEntryRepository @@ -60,12 +62,16 @@ class NotebookEntryControllerIntegrationTests { @Autowired private lateinit var passwordEncoder: PasswordEncoder + @Autowired + private lateinit var classroomBannedWordRepository: ClassroomBannedWordRepository + private val jsonMapper = JsonMapper.builder().build() @BeforeEach fun setUp() { refreshTokenRepository.deleteAll() notebookEntryRepository.deleteAll() + classroomBannedWordRepository.deleteAll() pupilProfileRepository.deleteAll() mapStopsRepository.deleteAll() classroomRepository.deleteAll() @@ -302,6 +308,48 @@ class NotebookEntryControllerIntegrationTests { assertEquals("New content", persisted.content) } + @Test + fun `authenticated pupil cannot create notebook entry containing classroom banned word`() { + val teacher = userRepository.save(activeTeacher()) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) + val pupil = userRepository.save(activePupil("ada")) + pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) + classroomBannedWordRepository.save( + ClassroomBannedWord( + classroom = classroom, + createdByTeacherUserId = requireNotNull(teacher.id), + word = "clue", + ), + ) + val accessToken = pupilAccessToken("ada", "secret123") + + mockMvc + .post("/api/v1/users/me/notebook-entries") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = + """ + { + "classroomId": ${classroom.id}, + "stopId": ${stop.id}, + "sourceType": "PUPIL", + "title": "Field clue", + "content": "Remember this" + } + """.trimIndent() + }.andExpect { + status { isUnprocessableEntity() } + } + } + @Test fun `authenticated pupil cannot delete another pupils notebook entry`() { val teacher = userRepository.save(activeTeacher()) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt index b5fe9417..d20ed81c 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt @@ -10,6 +10,7 @@ import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService import edu.ntnu.idi.idatt.backend.notebook.application.CreateNotebookEntryCommand import edu.ntnu.idi.idatt.backend.notebook.application.DeleteNotebookEntryCommand import edu.ntnu.idi.idatt.backend.notebook.application.NotebookService @@ -37,6 +38,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -45,6 +47,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -69,6 +72,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -77,6 +81,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) @@ -102,6 +107,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -110,6 +116,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) @@ -138,6 +145,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -146,6 +154,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -190,6 +199,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -198,6 +208,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -231,6 +242,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -239,6 +251,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -272,6 +285,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -280,6 +294,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -314,6 +329,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -322,6 +338,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -357,6 +374,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -365,6 +383,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -396,6 +415,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -404,6 +424,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -430,6 +451,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -438,6 +460,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = currentUser("42") val pupil = pupilUser(42L) @@ -467,6 +490,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -475,6 +499,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) @@ -499,6 +524,7 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) + val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) val service = NotebookService( notebookEntryRepository, @@ -507,6 +533,7 @@ class NotebookServiceTests { mapStopsRepository, pupilGameProgressService, xpService, + classroomModerationService, ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) From d8a5a5d3bd6b975734166cfa66de22d055d6da37 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Tue, 28 Apr 2026 15:11:21 +0200 Subject: [PATCH 3/5] add bootstrap data --- .../backend/bootstrap/DevDataBootstrap.kt | 24 ++++++++++++++ .../ClassroomModerationReferenceController.kt | 32 +++++++++++++++++++ .../application/ClassroomModerationService.kt | 6 +++- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt index 37e4080a..861f229f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt @@ -23,6 +23,8 @@ import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookEntry import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookSourceType import edu.ntnu.idi.idatt.backend.notebook.infrastructure.NotebookEntryRepository +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile @@ -59,6 +61,7 @@ class DevDataBootstrap( private val refreshTokenRepository: RefreshTokenRepository, private val pupilGameProgressRepository: PupilGameProgressRepository, private val notebookEntryRepository: NotebookEntryRepository, + private val classroomBannedWordRepository: ClassroomBannedWordRepository, private val passwordEncoder: PasswordEncoder, private val mapStopBootstrap: MapStopBootstrap, private val medalBootstrap: MedalBootstrap, @@ -117,6 +120,8 @@ class DevDataBootstrap( seedPupilProfile(pupilAda, "Ada", sampleClassroom) seedPupilProfile(pupilNoah, "Noah", sampleClassroom) seedPupilProfile(pupilSofia, "Sofia", sampleClassroom) + seedClassroomBannedWord(sampleClassroom, teacher, "faen") + seedClassroomBannedWord(sampleClassroom, teacher, "fuck") seedPupilAvatar(pupilAda) seedPupilAvatar(pupilNoah) seedPupilAvatar(pupilSofia) @@ -420,6 +425,25 @@ class DevDataBootstrap( ) } + private fun seedClassroomBannedWord( + classroom: Classroom, + teacher: User, + word: String, + ) { + val classroomId = requireNotNull(classroom.id) { "Seed classroom must have an id" } + if (classroomBannedWordRepository.existsByClassroomIdAndWord(classroomId, word)) { + return + } + + classroomBannedWordRepository.save( + ClassroomBannedWord( + classroom = classroom, + createdByTeacherUserId = requireNotNull(teacher.id) { "Teacher seed user must have an id" }, + word = word, + ), + ) + } + private fun seedNotebookEntries( classroom: Classroom, pupilAda: User, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt new file mode 100644 index 00000000..ef84a897 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt @@ -0,0 +1,32 @@ +package edu.ntnu.idi.idatt.backend.moderation.api + +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.security.CurrentUser +import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/classrooms/{classroomId}/moderation/banned-words") +class ClassroomModerationReferenceController( + private val classroomModerationService: ClassroomModerationService, + private val currentUserResolver: CurrentUserResolver, + private val classroomUserResolver: ClassroomUserResolver, +) { + @GetMapping + fun listBannedWordsForClassroomMember( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val user = currentUserResolver.requireUser(currentUser.subject) + classroomUserResolver.requireClassroomMember(user, classroomId) + + return classroomModerationService + .listBannedWordsForClassroom(classroomId) + .map { it.toResponse() } + } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt index b8efa008..e56c1b84 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt @@ -16,6 +16,10 @@ class ClassroomModerationService( private val classroomUserResolver: ClassroomUserResolver, private val classroomBannedWordRepository: ClassroomBannedWordRepository, ) { + @Transactional(readOnly = true) + fun listBannedWordsForClassroom(classroomId: Long): List = + classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + @Transactional(readOnly = true) fun listBannedWords( authenticatedSubject: String, @@ -24,7 +28,7 @@ class ClassroomModerationService( val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) - return classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + return listBannedWordsForClassroom(classroomId) } @Transactional From 62343b87fd4dce0aee3d6d79c836de2cdf9483f6 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Tue, 28 Apr 2026 15:47:08 +0200 Subject: [PATCH 4/5] format --- .../edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt | 4 ++-- .../moderation/application/ClassroomModerationService.kt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt index 861f229f..daf9fb0a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt @@ -20,11 +20,11 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord +import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookEntry import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookSourceType import edu.ntnu.idi.idatt.backend.notebook.infrastructure.NotebookEntryRepository -import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord -import edu.ntnu.idi.idatt.backend.moderation.infrastructure.ClassroomBannedWordRepository import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt index e56c1b84..22b14ce3 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt @@ -17,8 +17,7 @@ class ClassroomModerationService( private val classroomBannedWordRepository: ClassroomBannedWordRepository, ) { @Transactional(readOnly = true) - fun listBannedWordsForClassroom(classroomId: Long): List = - classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + fun listBannedWordsForClassroom(classroomId: Long): List = classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) @Transactional(readOnly = true) fun listBannedWords( From 60f3b1c3ac8ec69c2632d193f92ea07093a0a526 Mon Sep 17 00:00:00 2001 From: eilifhl Date: Wed, 29 Apr 2026 09:24:12 +0200 Subject: [PATCH 5/5] kdocs --- .../moderation/api/ClassroomBannedWordApi.kt | 24 ++++++++ .../api/ClassroomModerationController.kt | 18 ++++++ .../ClassroomModerationReferenceController.kt | 13 ++++ .../ClassroomModerationCommands.kt | 13 ++++ .../application/ClassroomModerationService.kt | 60 +++++++++++++++++++ .../moderation/domain/ClassroomBannedWord.kt | 5 ++ .../ClassroomBannedWordRepository.kt | 12 ++++ 7 files changed, 145 insertions(+) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt index 6eb6ee5d..bf1ae25c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomBannedWordApi.kt @@ -7,12 +7,27 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size import java.time.Instant +/** + * Request payload for creating or updating one classroom banned word. + * + * @property word teacher-supplied word or phrase to persist + */ data class ClassroomBannedWordRequest( @field:NotBlank @field:Size(max = 100) val word: String, ) +/** + * API response representing one persisted classroom banned word. + * + * @property id database identifier + * @property classroomId owning classroom + * @property word normalized blocked word or phrase + * @property createdByTeacherUserId teacher who originally created the row + * @property createdAt timestamp when the row was created + * @property updatedAt timestamp when the row was last updated + */ data class ClassroomBannedWordResponse( val id: Long, val classroomId: Long, @@ -22,12 +37,18 @@ data class ClassroomBannedWordResponse( val updatedAt: Instant?, ) +/** + * Maps one API request payload into an application-layer create command. + */ fun ClassroomBannedWordRequest.toCreateCommand(classroomId: Long): CreateClassroomBannedWordCommand = CreateClassroomBannedWordCommand( classroomId = classroomId, word = word, ) +/** + * Maps one API request payload into an application-layer update command. + */ fun ClassroomBannedWordRequest.toUpdateCommand( classroomId: Long, bannedWordId: Long, @@ -38,6 +59,9 @@ fun ClassroomBannedWordRequest.toUpdateCommand( word = word, ) +/** + * Maps one persisted banned-word entity into its API response shape. + */ fun ClassroomBannedWord.toResponse(): ClassroomBannedWordResponse = ClassroomBannedWordResponse( id = requireNotNull(id) { "Banned word is missing an id" }, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt index 168c2fa9..aa801509 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationController.kt @@ -16,12 +16,21 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +/** + * Teacher-facing moderation endpoints for managing classroom banned words. + * + * @property classroomModerationService application service handling banned-word persistence and + * access validation + */ @RestController @RequestMapping("/api/v1/teacher/classrooms/{classroomId}/moderation/banned-words") @PreAuthorize("hasAnyRole('TEACHER', 'ADMIN')") class ClassroomModerationController( private val classroomModerationService: ClassroomModerationService, ) { + /** + * Returns all banned words configured for the classroom. + */ @GetMapping fun listBannedWords( @AuthenticationPrincipal currentUser: CurrentUser, @@ -31,6 +40,9 @@ class ClassroomModerationController( .listBannedWords(currentUser.subject, classroomId) .map { it.toResponse() } + /** + * Creates one new banned word for the classroom. + */ @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createBannedWord( @@ -42,6 +54,9 @@ class ClassroomModerationController( .createBannedWord(currentUser.subject, request.toCreateCommand(classroomId)) .toResponse() + /** + * Updates one existing banned word for the classroom. + */ @PutMapping("/{bannedWordId}") fun updateBannedWord( @AuthenticationPrincipal currentUser: CurrentUser, @@ -53,6 +68,9 @@ class ClassroomModerationController( .updateBannedWord(currentUser.subject, request.toUpdateCommand(classroomId, bannedWordId)) .toResponse() + /** + * Deletes one existing banned word for the classroom. + */ @DeleteMapping("/{bannedWordId}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteBannedWord( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt index ef84a897..6af81f43 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/api/ClassroomModerationReferenceController.kt @@ -10,6 +10,16 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +/** + * Classroom-member-facing read endpoint for moderation reference data. + * + * Pupils use this endpoint to fetch the current classroom banned-word list for immediate client + * validation, while the backend remains the source of truth for actual submission blocking. + * + * @property classroomModerationService application service used to load persisted banned words + * @property currentUserResolver shared resolver for the authenticated caller + * @property classroomUserResolver classroom membership guard + */ @RestController @RequestMapping("/api/v1/classrooms/{classroomId}/moderation/banned-words") class ClassroomModerationReferenceController( @@ -17,6 +27,9 @@ class ClassroomModerationReferenceController( private val currentUserResolver: CurrentUserResolver, private val classroomUserResolver: ClassroomUserResolver, ) { + /** + * Returns the banned words for one classroom after verifying that the caller is a member. + */ @GetMapping fun listBannedWordsForClassroomMember( @AuthenticationPrincipal currentUser: CurrentUser, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt index 06b74df7..9698b5f5 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationCommands.kt @@ -1,10 +1,23 @@ package edu.ntnu.idi.idatt.backend.moderation.application +/** + * Command representing the intent to create one classroom-scoped banned word. + * + * @property classroomId classroom that owns the moderation rule + * @property word teacher-supplied word or phrase to block + */ data class CreateClassroomBannedWordCommand( val classroomId: Long, val word: String, ) +/** + * Command representing the intent to update one existing classroom-scoped banned word. + * + * @property classroomId classroom that owns the moderation rule + * @property bannedWordId identifier of the banned-word row being edited + * @property word replacement word or phrase to persist + */ data class UpdateClassroomBannedWordCommand( val classroomId: Long, val bannedWordId: Long, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt index 22b14ce3..95bb1f66 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/application/ClassroomModerationService.kt @@ -10,15 +10,37 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException import java.util.Locale +/** + * Application service for classroom moderation rules and text blocking. + * + * Teachers use this service to manage classroom-scoped banned words, while notebook and mystery + * flows reuse it to reject submissions containing configured blocked words. + * + * @property currentUserResolver shared resolver for authenticated teacher/admin access + * @property classroomUserResolver classroom-scoped access guard + * @property classroomBannedWordRepository repository for persisted moderation rules + */ @Service class ClassroomModerationService( private val currentUserResolver: CurrentUserResolver, private val classroomUserResolver: ClassroomUserResolver, private val classroomBannedWordRepository: ClassroomBannedWordRepository, ) { + /** + * Returns all banned words configured for one classroom without applying an auth check. + * + * Intended for callers that have already validated classroom membership or teacher access. + */ @Transactional(readOnly = true) fun listBannedWordsForClassroom(classroomId: Long): List = classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + /** + * Returns all banned words for a classroom after verifying teacher/admin access. + * + * @param authenticatedSubject subject of the authenticated caller + * @param classroomId classroom whose moderation rules should be listed + * @return classroom-scoped banned words ordered alphabetically + */ @Transactional(readOnly = true) fun listBannedWords( authenticatedSubject: String, @@ -30,6 +52,13 @@ class ClassroomModerationService( return listBannedWordsForClassroom(classroomId) } + /** + * Persists one new classroom-scoped banned word after verifying teacher/admin access. + * + * @param authenticatedSubject subject of the authenticated caller + * @param command create payload containing classroom id and word + * @return the saved banned-word row + */ @Transactional fun createBannedWord( authenticatedSubject: String, @@ -52,6 +81,13 @@ class ClassroomModerationService( ) } + /** + * Updates one existing classroom-scoped banned word after verifying teacher/admin access. + * + * @param authenticatedSubject subject of the authenticated caller + * @param command update payload containing classroom id, row id, and replacement word + * @return the updated banned-word row + */ @Transactional fun updateBannedWord( authenticatedSubject: String, @@ -75,6 +111,13 @@ class ClassroomModerationService( return classroomBannedWordRepository.save(bannedWord) } + /** + * Deletes one classroom-scoped banned word after verifying teacher/admin access. + * + * @param authenticatedSubject subject of the authenticated caller + * @param classroomId classroom that owns the banned word + * @param bannedWordId identifier of the row to remove + */ @Transactional fun deleteBannedWord( authenticatedSubject: String, @@ -91,6 +134,14 @@ class ClassroomModerationService( classroomBannedWordRepository.delete(bannedWord) } + /** + * Rejects pupil-controlled text when any configured banned word matches one of the supplied + * text fragments. + * + * @param classroomId classroom whose moderation rules should be applied + * @param texts one or more free-text fragments to validate + * @throws ResponseStatusException 422 when any configured banned word matches + */ @Transactional(readOnly = true) fun assertNoBannedWords( classroomId: Long, @@ -111,6 +162,9 @@ class ClassroomModerationService( } } + /** + * Normalizes one teacher-supplied banned word and rejects blank values. + */ private fun normalizeWord(word: String): String { val normalized = word.trim().lowercase(Locale.ROOT) if (normalized.isBlank()) { @@ -119,6 +173,12 @@ class ClassroomModerationService( return normalized } + /** + * Returns whether one text fragment contains a configured banned word or phrase. + * + * Matching is case-insensitive and treats the configured word as a bounded token/phrase so + * substrings inside larger words do not trigger by accident. + */ private fun containsConfiguredWord( text: String, configuredWord: String, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt index 329c0720..6f88c7ff 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/domain/ClassroomBannedWord.kt @@ -43,6 +43,11 @@ class ClassroomBannedWord( @Column(name = "updated_at", nullable = false) var updatedAt: Instant? = null, ) { + /** + * Normalizes the configured word before insert and update. + * + * Keeps duplicate detection and moderation matching case-insensitive and whitespace-stable. + */ @PrePersist @PreUpdate fun normalizeWord() { diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt index 23fc4f45..a0c811bd 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/moderation/infrastructure/ClassroomBannedWordRepository.kt @@ -4,15 +4,27 @@ import edu.ntnu.idi.idatt.backend.moderation.domain.ClassroomBannedWord import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository +/** + * Repository for classroom-scoped banned-word persistence and lookup. + */ @Repository interface ClassroomBannedWordRepository : JpaRepository { + /** + * Returns whether the classroom already has the supplied normalized banned word. + */ fun existsByClassroomIdAndWord( classroomId: Long, word: String, ): Boolean + /** + * Loads all banned words for one classroom ordered alphabetically for stable presentation. + */ fun findAllByClassroomIdOrderByWordAsc(classroomId: Long): List + /** + * Loads one banned word only when it belongs to the supplied classroom. + */ fun findByIdAndClassroomId( id: Long, classroomId: Long,