Skip to content

feat: behaviour control #70

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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<String> {
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class NotificationDeliveryService(
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun deliverToClassroomTeachers(draft: NotificationDraft): List<TeacherNotification> {
println("DELIVERING NOTIFICATION")
val recipients = recipientResolver.resolveTeacherRecipientIds(draft.classroomId)
val notifications = mutableListOf<TeacherNotification>()

Expand Down Expand Up @@ -67,6 +68,7 @@ class NotificationDeliveryService(
}
}

println("NOTIFICATIONS: $notifications")
return notifications
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ enum class NotificationType {
GAME_COMPLETED,
PUPIL_STUCK,
WEEKLY_MYSTERY_SUBMITTED,
INAPPROPRIATE_BEHAVIOUR,
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading