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 95bb1f66..257c3a11 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 @@ -4,6 +4,8 @@ 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 edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher +import edu.ntnu.idi.idatt.backend.shared.events.InappropriateBehaviourEvent import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,6 +27,7 @@ class ClassroomModerationService( private val currentUserResolver: CurrentUserResolver, private val classroomUserResolver: ClassroomUserResolver, private val classroomBannedWordRepository: ClassroomBannedWordRepository, + private val domainEventPublisher: DomainEventPublisher, ) { /** * Returns all banned words configured for one classroom without applying an auth check. @@ -146,22 +149,57 @@ class ClassroomModerationService( fun assertNoBannedWords( classroomId: Long, vararg texts: String, + pupilUserId: Long? = null, + context: String = "UNKNOWN", ) { val configuredWords = classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) if (configuredWords.isEmpty()) { return } + val combined = texts.joinToString(" ") + val blocked = configuredWords.firstOrNull { bannedWord -> texts.any { text -> containsConfiguredWord(text, bannedWord.word) } } if (blocked != null) { + if (pupilUserId != null) { + domainEventPublisher.publish( + InappropriateBehaviourEvent( + pupilUserId = pupilUserId, + classroomId = classroomId, + input = combined, + context = context, + ), + ) + } + println("BLOCKED WORDS DETECTED: $blocked") throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Text contains banned words") } } + fun findBannedWordViolations( + classroomId: Long, + vararg texts: String, + ): List { + val configuredWords = classroomBannedWordRepository.findAllByClassroomIdOrderByWordAsc(classroomId) + + if (configuredWords.isEmpty()) return emptyList() + + val normalizedTexts = texts.filter { it.isNotBlank() } + + return configuredWords + .mapNotNull { banned -> + val matched = + normalizedTexts.any { text -> + containsConfiguredWord(text, banned.word) + } + if (matched) banned.word else null + } + } + /** * Normalizes one teacher-supplied banned word and rejects blank values. */ 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 543f0da5..3f8f0123 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 @@ -47,7 +47,12 @@ class MysteryService( */ @Transactional fun submitExample(command: SubmitMysteryCommand): MysterySubmission { - classroomModerationService.assertNoBannedWords(command.classroomId, command.description) + classroomModerationService.assertNoBannedWords( + command.classroomId, + command.description, + pupilUserId = command.pupilUserId, + context = "MYSTERY_SUBMISSION", + ) val pupil = iamUserService.findById(command.pupilUserId) 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 ddeeb38d..d017b925 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 @@ -105,7 +105,13 @@ class NotebookService( ) } - classroomModerationService.assertNoBannedWords(command.classroomId, command.title, command.content) + classroomModerationService.assertNoBannedWords( + classroomId = command.classroomId, + command.title, + command.content, + pupilUserId = pupil.id, + context = "NOTEBOOK_ENTRY_CREATE", + ) val stop = mapStopService.getMapStopById(command.stopId) @@ -135,9 +141,11 @@ class NotebookService( val notebookEntry = verifyOwnership(pupil.id, command.notebookEntryId) classroomModerationService.assertNoBannedWords( - requireNotNull(notebookEntry.classroom.id) { "Notebook classroom is missing an id" }, + classroomId = requireNotNull(notebookEntry.classroom.id), command.title, command.content, + pupilUserId = pupil.id, + context = "NOTEBOOK_ENTRY_UPDATE", ) notebookEntry.title = command.title diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt index a3f28787..74325dc6 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt @@ -27,6 +27,7 @@ class NotificationDeliveryService( */ @Transactional(propagation = Propagation.REQUIRES_NEW) fun deliverToClassroomTeachers(draft: NotificationDraft): List { + println("DELIVERING NOTIFICATION") val recipients = recipientResolver.resolveTeacherRecipientIds(draft.classroomId) val notifications = mutableListOf() @@ -67,6 +68,7 @@ class NotificationDeliveryService( } } + println("NOTIFICATIONS: $notifications") return notifications } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt index c6de5d0b..77b8feff 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt @@ -22,6 +22,11 @@ data class StopNotificationContext( val stopTitle: String, ) +data class PupilNotificationContext( + val classroomTitle: String, + val pupilDisplayName: String, +) + /** * Resolves domain context needed to build teacher notification render arguments. */ @@ -64,6 +69,27 @@ class NotificationEventContextResolver( ) } + fun resolvePupilContext( + classroomId: Long, + pupilUserId: Long, + ): PupilNotificationContext { + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") } + + val pupilProfile = pupilProfileRepository.findById(pupilUserId).orElse(null) + val pupil = + userRepository + .findById(pupilUserId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") } + + return PupilNotificationContext( + classroomTitle = classroom.title, + pupilDisplayName = pupilProfile?.displayName ?: pupil.username ?: "Elev", + ) + } + /** * Serializes a small ordered key-value payload to JSON for frontend rendering arguments. * diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt index a09addf3..cb7a15d1 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt @@ -2,6 +2,7 @@ package edu.ntnu.idi.idatt.backend.notification.application import edu.ntnu.idi.idatt.backend.notification.domain.NotificationRenderCode import edu.ntnu.idi.idatt.backend.notification.domain.NotificationType +import edu.ntnu.idi.idatt.backend.shared.events.InappropriateBehaviourEvent import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilRecoveredFromStuckEvent @@ -146,6 +147,49 @@ class TeacherNotificationEventListener( ) } + /** + * Materializes an inappropriate behaviour notification after a pupil attempts to write banned words. + * + * @param event committed inappropriate behaviour business event + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onInappropriateBehaviour(event: InappropriateBehaviourEvent) { + val context = + notificationEventContextResolver.resolvePupilContext( + classroomId = event.classroomId, + pupilUserId = event.pupilUserId, + ) + val threadKey = + inappropriateBehaviourThreadKey( + event.classroomId, + event.pupilUserId, + ) + + notificationDeliveryService.deliverToClassroomTeachers( + NotificationDraft( + classroomId = event.classroomId, + pupilUserId = event.pupilUserId, + type = NotificationType.INAPPROPRIATE_BEHAVIOUR, + threadKey = threadKey, + renderCode = NotificationRenderCode.INAPPROPRIATE_BEHAVIOR, + renderArgsJson = + notificationEventContextResolver.json( + "classroomTitle" to context.classroomTitle, + "pupilDisplayName" to context.pupilDisplayName, + "input" to event.input, + "context" to event.context, + ), + payloadJson = + notificationEventContextResolver.json( + "classroomId" to event.classroomId, + "pupilUserId" to event.pupilUserId, + "input" to event.input, + "context" to event.context, + ), + ), + ) + } + companion object { /** * Stable thread key for stop-completion notifications for one pupil/stop/classroom tuple. @@ -186,5 +230,17 @@ class TeacherNotificationEventListener( pupilUserId: Long, stopId: Long, ): String = "stuck:$classroomId:$pupilUserId:$stopId" + + /** + * Stable thread key for inappropriate behavior notifications for one pupil/classroom tuple. + * + * @param classroomId classroom that scopes the notification thread + * @param pupilUserId pupil that has behaved inappropriately + * @return deterministic notification thread key + */ + fun inappropriateBehaviourThreadKey( + classroomId: Long, + pupilUserId: Long, + ): String = "inappropriate:$classroomId:$pupilUserId" } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationRenderCode.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationRenderCode.kt index 6ad487fb..7a561d27 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationRenderCode.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationRenderCode.kt @@ -10,4 +10,5 @@ enum class NotificationRenderCode( GAME_COMPLETED("notifications.game-completed"), PUPIL_STUCK("notifications.pupil-stuck"), WEEKLY_MYSTERY_SUBMITTED("notifications.weekly-mystery-submitted"), + INAPPROPRIATE_BEHAVIOR("notifications.inappropriate-behavior"), } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationType.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationType.kt index 6628c231..92f49cdb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationType.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/domain/NotificationType.kt @@ -8,4 +8,5 @@ enum class NotificationType { GAME_COMPLETED, PUPIL_STUCK, WEEKLY_MYSTERY_SUBMITTED, + INAPPROPRIATE_BEHAVIOUR, } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/InappropriateBehaviourEvent.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/InappropriateBehaviourEvent.kt new file mode 100644 index 00000000..477145b7 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/InappropriateBehaviourEvent.kt @@ -0,0 +1,11 @@ +package edu.ntnu.idi.idatt.backend.shared.events + +import java.time.Instant + +data class InappropriateBehaviourEvent( + val pupilUserId: Long, + val classroomId: Long, + val input: String, + val context: String, + override val occurredAt: Instant = Instant.now(), +) : DomainEvent diff --git a/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql b/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql index 9c8a9a5f..93b14b1d 100644 --- a/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql +++ b/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql @@ -7,7 +7,8 @@ CREATE TABLE teacher_notifications type ENUM('MAP_STOP_COMPLETED', 'GAME_COMPLETED', 'PUPIL_STUCK', - 'WEEKLY_MYSTERY_SUBMITTED' + 'WEEKLY_MYSTERY_SUBMITTED', + 'INAPPROPRIATE_BEHAVIOUR' ) NOT NULL, thread_key VARCHAR(255), active_thread_key VARCHAR(255),