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 ad6af95b..2578e20e 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. */ @@ -75,6 +80,23 @@ 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..024e9654 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 @@ -6,6 +6,7 @@ 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 import edu.ntnu.idi.idatt.backend.shared.events.PupilStuckThresholdReachedEvent +import edu.ntnu.idi.idatt.backend.shared.events.InappropriateBehaviourEvent import org.springframework.stereotype.Component import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener @@ -146,6 +147,48 @@ 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 +229,19 @@ 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..aa0ac5ce --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/InappropriateBehaviourEvent.kt @@ -0,0 +1,13 @@ +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..84f6bf8e 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),