Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class DevDataBootstrap(
private val xpBootstrap: XpBootstrap,
private val pupilProgressSeedingService: PupilProgressSeedingService,
private val pupilStopProgressRepository: PupilStopProgressRepository,
private val mysteryBootstrap: MysteryBootstrap,
) : ApplicationRunner {
private val logger = LoggerFactory.getLogger(javaClass)

Expand Down Expand Up @@ -194,7 +195,13 @@ class DevDataBootstrap(

pupilProgressSeedingService.seedIfAbsent(pupil = pupilNoah, classroom = sampleClassroom)
seedAllStopsUnlocked(pupil = pupilSofia, classroom = sampleClassroom, stops = activeStops)

mysteryBootstrap.seedAllMysteries(
classroom = sampleClassroom,
teacher = teacher,
pupilAda = pupilAda,
pupilNoah = pupilNoah,
pupilSofia = pupilSofia,
)
logger.info(
"Development bootstrap ensured sample rows for users, profiles, refresh tokens, classroom {} with join code {}, and seeded tasks for {} active map stops",
sampleClassroom.title,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package edu.ntnu.idi.idatt.backend.bootstrap

import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.iam.domain.User
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
import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository
import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository
import org.springframework.stereotype.Component
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters

/**
* Seeds deterministic mystery submissions and weekly featured mysteries for local development.
*
* Produces:
* - Three approved submissions (one per pupil), two of which are featured this week
* - One pending submission from Ada
* - One rejected submission from Noah
* - One approved submission featured in the previous week (to test week navigation)
*
* Idempotent — skips seeding if any submission already exists for the classroom.
*/
@Component
class MysteryBootstrap(
private val submissionRepository: MysterySubmissionRepository,
private val weeklyMysteryRepository: WeeklyMysteryRepository,
) {
fun seedAllMysteries(classroom: Classroom, teacher: User, pupilAda: User, pupilNoah: User, pupilSofia: User) {
if (submissionRepository.findAllByClassroomId(classroom.id).isNotEmpty()) return

val thisWeek = currentWeekStart()
val lastWeek = thisWeek.minusWeeks(1)

// Ada — approved, featured this week
val adaSubmission = submissionRepository.save(
MysterySubmission(
classroom = classroom,
pupil = pupilAda,
description = "Jeg fant denne artikkelen på nettet som hevder at sjokolade kurerer forkjølelse. Ingen kilder er oppgitt og avsender er ukjent.",
attachmentUrl = null,
).also { it.approve(teacher, "Flott funn! Du la godt merke til at det mangler kilder.") }
)

// Noah — approved, featured this week
val noahSubmission = submissionRepository.save(
MysterySubmission(
classroom = classroom,
pupil = pupilNoah,
description = "Et innlegg på sosiale medier som sier at en kjendis har sagt noe de aldri har sagt. Bildet ser manipulert ut.",
attachmentUrl = null,
).also { it.approve(teacher, "Godt observert! Bildesøk er et nyttig verktøy.") }
)

// Sofia — approved, featured last week (tests week navigation)
val sofiaSubmission = submissionRepository.save(
MysterySubmission(
classroom = classroom,
pupil = pupilSofia,
description = "En epost som ser ut som den er fra banken, men avsenderadressen er veldig merkelig og lenken går til et ukjent nettsted.",
attachmentUrl = null,
).also { it.approve(teacher, "Veldig bra! Dette er et typisk phishing-forsøk.") }
)

// Ada — pending (tests the 'mine' tab with pending status)
submissionRepository.save(
MysterySubmission(
classroom = classroom,
pupil = pupilAda,
description = "Jeg fant et innlegg som påstår at jordet er flat, med mange likes og delinger. Ingen vitenskapelige kilder.",
attachmentUrl = null,
)
)

// Noah — rejected (tests the 'mine' tab with rejected status)
submissionRepository.save(
MysterySubmission(
classroom = classroom,
pupil = pupilNoah,
description = "En reklame som sier du har vunnet en premie.",
attachmentUrl = null,
).also { it.reject(teacher, "Dette er reklame, ikke fake news. Prøv å finne et bedre eksempel.") }
)

// Feature ada + noah this week, sofia last week
weeklyMysteryRepository.save(
WeeklyMystery(
classroom = classroom,
submission = adaSubmission,
weekStart = thisWeek,
featuredBy = teacher,
)
)
weeklyMysteryRepository.save(
WeeklyMystery(
classroom = classroom,
submission = noahSubmission,
weekStart = thisWeek,
featuredBy = teacher,
)
)
weeklyMysteryRepository.save(
WeeklyMystery(
classroom = classroom,
submission = sofiaSubmission,
weekStart = lastWeek,
featuredBy = teacher,
)
)
}

private fun currentWeekStart(): LocalDate =
LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ class SecurityConfig(
"/error",
).permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf").permitAll()
// public uploads endpoint for now, since images are stored directly on the backend
// instead of a dedicated file storage system
.requestMatchers(HttpMethod.GET, "/uploads/**").permitAll()
.requestMatchers(
HttpMethod.POST,
"/api/v1/auth/teacher/register",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package edu.ntnu.idi.idatt.backend.mystery.api
import java.time.LocalDate

/**
* Teacher request to feature an approved submission for a given week.
*
* @property submissionId The approved submission to feature.
* @property weekStart The Monday of the ISO week to feature it in.
*/
data class FeatureMysteryRequest(
val submissionId: Long,
val weekStart: LocalDate,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package edu.ntnu.idi.idatt.backend.mystery.api

import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission
import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery

fun MysterySubmission.toResponse(): MysterySubmissionResponse =
MysterySubmissionResponse(
id = id,
classroomId = requireNotNull(classroom.id) { "Classroom id missing" },
pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" },
description = description,
attachmentUrl = attachmentUrl,
submittedAt = submittedAt,
reviewStatus = reviewStatus.name,
reviewedByTeacherId = reviewedBy?.id,
reviewedAt = reviewedAt,
teacherComment = teacherComment,
)

fun WeeklyMystery.toResponse(): WeeklyMysteryResponse =
WeeklyMysteryResponse(
id = id,
classroomId = requireNotNull(classroom.id) { "Classroom id missing" },
weekStart = weekStart,
featuredByTeacherId = requireNotNull(featuredBy.id) { "Teacher id missing" },
featuredAt = featuredAt,
submission = submission.toResponse(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package edu.ntnu.idi.idatt.backend.mystery.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.mystery.application.MysteryService
import edu.ntnu.idi.idatt.backend.mystery.application.SubmitMysteryCommand
import edu.ntnu.idi.idatt.backend.upload.application.FileStorageService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

/**
* Pupil-facing endpoints for the weekly mystery feature.
*
* Pupils can browse this week's featured mysteries, view all past mysteries,
* submit new examples, and track the review status of their own submissions.
*
* @property mysteryService application service handling mystery operations
* @property currentUserResolver resolves and validates the authenticated user
* @property classroomUserResolver enforces classroom membership
* @property fileStorageService handles image file persistence
*/
@RestController
@RequestMapping("/api/v1/classrooms/{classroomId}/mystery")
@PreAuthorize("hasRole('PUPIL')")
class MysteryController(
private val mysteryService: MysteryService,
private val currentUserResolver: CurrentUserResolver,
private val classroomUserResolver: ClassroomUserResolver,
private val fileStorageService: FileStorageService,
) {
/**
* Returns all mysteries featured for the current ISO week.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @param classroomId classroom to scope the lookup to
* @return featured mysteries for this week, may be empty
*/
@GetMapping("/weekly/current")
fun getCurrentWeekMysteries(
@AuthenticationPrincipal currentUser: CurrentUser,
@PathVariable classroomId: Long,
): List<WeeklyMysteryResponse> {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
classroomUserResolver.requireClassroomMember(pupil, classroomId)
return mysteryService.getCurrentWeekMysteries(classroomId).map { it.toResponse() }
}

/**
* Returns all weekly mysteries for the classroom ordered by week descending.
* Used by pupils to browse previous weeks.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @param classroomId classroom to scope the lookup to
* @return all weekly mysteries, newest week first
*/
@GetMapping("/weekly")
fun getAllWeeklyMysteries(
@AuthenticationPrincipal currentUser: CurrentUser,
@PathVariable classroomId: Long,
): List<WeeklyMysteryResponse> {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
classroomUserResolver.requireClassroomMember(pupil, classroomId)
return mysteryService.getAllWeeklyMysteries(classroomId).map { it.toResponse() }
}

/**
* Submits a new mystery example for teacher review.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @param classroomId classroom to submit the example to
* @param request submission payload
* @return the created submission
*/
@PostMapping("/submissions")
@ResponseStatus(HttpStatus.CREATED)
fun submitExample(
@AuthenticationPrincipal currentUser: CurrentUser,
@PathVariable classroomId: Long,
@Valid @RequestBody request: SubmitMysteryRequest,
): MysterySubmissionResponse {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
val classroom = classroomUserResolver.requireClassroomMember(pupil, classroomId)
return mysteryService.submitExample(
SubmitMysteryCommand(
classroomId = classroomId,
classroom = classroom,
pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" },
description = request.description,
attachmentUrl = request.attachmentUrl,
)
).toResponse()
}

/**
* Uploads an image attachment for a mystery submission.
*
* Files are stored under uploads/mystery/classroom-{classroomId}/.
* Returns the public URL to include as [attachmentUrl] in a subsequent submission.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @param classroomId classroom to scope the upload to
* @param file the image file to upload
* @return the public URL of the stored file
*/
@PostMapping("/upload", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@ResponseStatus(HttpStatus.CREATED)
fun uploadAttachment(
@AuthenticationPrincipal currentUser: CurrentUser,
@PathVariable classroomId: Long,
@RequestParam("file") file: MultipartFile,
): Map<String, String> {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
classroomUserResolver.requireClassroomMember(pupil, classroomId)
val url = fileStorageService.store(file, "mystery/classroom-$classroomId")
return mapOf("url" to url)
}

/**
* Returns the authenticated pupil's own submissions and their review status.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @param classroomId classroom to scope the lookup to
* @return the pupil's submissions in this classroom
*/
@GetMapping("/submissions/my")
fun getMySubmissions(
@AuthenticationPrincipal currentUser: CurrentUser,
@PathVariable classroomId: Long,
): List<MysterySubmissionResponse> {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
classroomUserResolver.requireClassroomMember(pupil, classroomId)
return mysteryService.getMySubmissions(
classroomId = classroomId,
pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" },
).map { it.toResponse() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package edu.ntnu.idi.idatt.backend.mystery.api

/**
* Serialized mystery submission returned by API endpoints.
*
* @property id Submission identifier.
* @property classroomId Classroom the submission belongs to.
* @property pupilUserId Pupil who submitted the example.
* @property description The pupil's written comment.
* @property attachmentUrl Optional URL to an uploaded image.
* @property submittedAt Timestamp when the submission was created.
* @property reviewStatus Current review state.
* @property reviewedByTeacherId Teacher who reviewed the submission, if reviewed.
* @property reviewedAt Timestamp when the submission was reviewed.
* @property teacherComment Optional feedback from the reviewing teacher.
*/
data class MysterySubmissionResponse(
val id: Long,
val classroomId: Long,
val pupilUserId: Long,
val description: String,
val attachmentUrl: String?,
val submittedAt: java.time.Instant?,
val reviewStatus: String,
val reviewedByTeacherId: Long?,
val reviewedAt: java.time.Instant?,
val teacherComment: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package edu.ntnu.idi.idatt.backend.mystery.api

import jakarta.validation.constraints.Size
/**
* Teacher request to approve or reject a submission.
*
* @property comment Optional feedback shown to the pupil.
*/
data class ReviewSubmissionRequest(
@field:Size(max = 1000)
val comment: String?,
)
Loading