@@ -279,13 +279,21 @@ public QuizSubmission saveSubmissionForLiveMode(Long exerciseId, QuizSubmission
279279 String logText = submitted ? "submit quiz in live mode:" : "save quiz in live mode:" ;
280280
281281 long start = System .nanoTime ();
282- var quizExercise = quizExerciseRepository .findByIdElseThrow (exerciseId );
282+ // Load questions eagerly so we can sanitize the client-submitted references below (answer options, drag items,
283+ // drop locations, short-answer spots). Without this, a stale id from the client causes Hibernate's merge to fail
284+ // the whole save with ObjectNotFoundException — see #12584.
285+ var quizExercise = quizExerciseRepository .findByIdWithQuestionsElseThrow (exerciseId );
283286 quizExercise .setQuizBatches (null );
284287 // A submission always exists because the user has to start the participation before submitting, which creates a submission
285288 var existingSubmission = quizSubmissionRepository .findByExerciseIdAndStudentLogin (quizExercise .getId (), userLogin )
286289 .orElseThrow (() -> new EntityNotFoundException ("Cannot find quiz submission for exercise " + exerciseId + " and user " + userLogin ));
287290 checkSubmissionForLiveModeOrThrow (quizExercise , existingSubmission , userLogin , logText , start );
288291
292+ // Replace client-supplied references with server-managed ones, and drop any that no longer exist. This protects
293+ // the downstream Hibernate merge from stale ids (e.g. after a quiz was re-imported) that would otherwise trigger
294+ // ObjectNotFoundException and abort the entire save.
295+ sanitizeSubmittedAnswersAgainstQuestions (quizSubmission , quizExercise );
296+
289297 // TODO: ideally we only save if something has changed, we can use "if (!isContentEqualTo(existingSubmission, quizSubmission))"
290298
291299 // recreate pointers back to submission in each submitted answer
@@ -369,6 +377,123 @@ private void checkSubmissionForLiveModeOrThrow(QuizExercise quizExercise, QuizSu
369377 // but for performance reasons the checks may have to be done in the quiz submission service where no feedback for the students can be generated
370378 }
371379
380+ /**
381+ * Replace every client-supplied reference inside the submitted answers (question, answer option, drag item, drop location, short-answer spot) with the server-side managed
382+ * instance loaded from {@code quizExercise}. Any reference that does not exist server-side is dropped. Submitted answers whose question is no longer part of the quiz are
383+ * removed entirely.
384+ *
385+ * <p>
386+ * This is a defensive sanitization: the live-submit endpoint still accepts a raw {@link QuizSubmission} from the client (see the TODO on
387+ * {@code QuizSubmissionResource.saveOrSubmitForLiveMode}). Without this step a single stale id — e.g. after a quiz was re-imported between tab open and submit — causes the
388+ * downstream Hibernate merge to abort the whole request with {@code ObjectNotFoundException} when it tries to resolve the reference (#12584).
389+ */
390+ private void sanitizeSubmittedAnswersAgainstQuestions (QuizSubmission quizSubmission , QuizExercise quizExercise ) {
391+ // Normalize a null collection to empty so downstream iterations (e.g. setSubmission on each answer) stay null-safe.
392+ if (quizSubmission .getSubmittedAnswers () == null ) {
393+ quizSubmission .setSubmittedAnswers (new HashSet <>());
394+ return ;
395+ }
396+ if (quizSubmission .getSubmittedAnswers ().isEmpty ()) {
397+ return ;
398+ }
399+ Map <Long , QuizQuestion > questionsById = quizExercise .getQuizQuestions ().stream ().filter (question -> question .getId () != null )
400+ .collect (Collectors .toMap (QuizQuestion ::getId , Function .identity ()));
401+ Set <SubmittedAnswer > sanitizedAnswers = new HashSet <>();
402+ for (SubmittedAnswer submittedAnswer : quizSubmission .getSubmittedAnswers ()) {
403+ QuizQuestion clientQuestion = submittedAnswer .getQuizQuestion ();
404+ if (clientQuestion == null || clientQuestion .getId () == null ) {
405+ continue ;
406+ }
407+ QuizQuestion serverQuestion = questionsById .get (clientQuestion .getId ());
408+ if (serverQuestion == null ) {
409+ continue ;
410+ }
411+ // Drop any submitted answer whose runtime subtype does not match the server-side question type — otherwise
412+ // we would attach e.g. an MC answer to a ShortAnswer question and pass an inconsistent entity to merge.
413+ if (submittedAnswer instanceof MultipleChoiceSubmittedAnswer mcAnswer && serverQuestion instanceof MultipleChoiceQuestion mcQuestion ) {
414+ submittedAnswer .setQuizQuestion (serverQuestion );
415+ sanitizeMultipleChoiceSelectedOptions (mcAnswer , mcQuestion );
416+ sanitizedAnswers .add (submittedAnswer );
417+ }
418+ else if (submittedAnswer instanceof DragAndDropSubmittedAnswer dndAnswer && serverQuestion instanceof DragAndDropQuestion dndQuestion ) {
419+ submittedAnswer .setQuizQuestion (serverQuestion );
420+ sanitizeDragAndDropMappings (dndAnswer , dndQuestion );
421+ sanitizedAnswers .add (submittedAnswer );
422+ }
423+ else if (submittedAnswer instanceof ShortAnswerSubmittedAnswer saAnswer && serverQuestion instanceof ShortAnswerQuestion saQuestion ) {
424+ submittedAnswer .setQuizQuestion (serverQuestion );
425+ sanitizeShortAnswerSubmittedTexts (saAnswer , saQuestion );
426+ sanitizedAnswers .add (submittedAnswer );
427+ }
428+ }
429+ quizSubmission .setSubmittedAnswers (sanitizedAnswers );
430+ }
431+
432+ private void sanitizeMultipleChoiceSelectedOptions (MultipleChoiceSubmittedAnswer submittedAnswer , MultipleChoiceQuestion question ) {
433+ Map <Long , AnswerOption > validOptions = question .getAnswerOptions ().stream ().filter (option -> option .getId () != null )
434+ .collect (Collectors .toMap (AnswerOption ::getId , Function .identity ()));
435+ Set <AnswerOption > clientOptions = submittedAnswer .getSelectedOptions ();
436+ Set <AnswerOption > sanitized = new HashSet <>();
437+ if (clientOptions != null ) {
438+ for (AnswerOption clientOption : clientOptions ) {
439+ if (clientOption == null || clientOption .getId () == null ) {
440+ continue ;
441+ }
442+ AnswerOption serverOption = validOptions .get (clientOption .getId ());
443+ if (serverOption != null ) {
444+ sanitized .add (serverOption );
445+ }
446+ }
447+ }
448+ submittedAnswer .setSelectedOptions (sanitized );
449+ }
450+
451+ private void sanitizeDragAndDropMappings (DragAndDropSubmittedAnswer submittedAnswer , DragAndDropQuestion question ) {
452+ Map <Long , DragItem > validDragItems = question .getDragItems ().stream ().filter (item -> item .getId () != null ).collect (Collectors .toMap (DragItem ::getId , Function .identity ()));
453+ Map <Long , DropLocation > validDropLocations = question .getDropLocations ().stream ().filter (location -> location .getId () != null )
454+ .collect (Collectors .toMap (DropLocation ::getId , Function .identity ()));
455+ Set <DragAndDropMapping > clientMappings = submittedAnswer .getMappings ();
456+ Set <DragAndDropMapping > sanitized = new HashSet <>();
457+ if (clientMappings != null ) {
458+ for (DragAndDropMapping mapping : clientMappings ) {
459+ if (mapping == null || mapping .getDragItem () == null || mapping .getDropLocation () == null || mapping .getDragItem ().getId () == null
460+ || mapping .getDropLocation ().getId () == null ) {
461+ continue ;
462+ }
463+ DragItem serverDragItem = validDragItems .get (mapping .getDragItem ().getId ());
464+ DropLocation serverDropLocation = validDropLocations .get (mapping .getDropLocation ().getId ());
465+ if (serverDragItem == null || serverDropLocation == null ) {
466+ continue ;
467+ }
468+ mapping .setDragItem (serverDragItem );
469+ mapping .setDropLocation (serverDropLocation );
470+ sanitized .add (mapping );
471+ }
472+ }
473+ submittedAnswer .setMappings (sanitized );
474+ }
475+
476+ private void sanitizeShortAnswerSubmittedTexts (ShortAnswerSubmittedAnswer submittedAnswer , ShortAnswerQuestion question ) {
477+ Map <Long , ShortAnswerSpot > validSpots = question .getSpots ().stream ().filter (spot -> spot .getId () != null )
478+ .collect (Collectors .toMap (ShortAnswerSpot ::getId , Function .identity ()));
479+ Set <ShortAnswerSubmittedText > clientTexts = submittedAnswer .getSubmittedTexts ();
480+ Set <ShortAnswerSubmittedText > sanitized = new HashSet <>();
481+ if (clientTexts != null ) {
482+ for (ShortAnswerSubmittedText submittedText : clientTexts ) {
483+ if (submittedText == null || submittedText .getSpot () == null || submittedText .getSpot ().getId () == null ) {
484+ continue ;
485+ }
486+ ShortAnswerSpot serverSpot = validSpots .get (submittedText .getSpot ().getId ());
487+ if (serverSpot == null ) {
488+ continue ;
489+ }
490+ submittedText .setSpot (serverSpot );
491+ sanitized .add (submittedText );
492+ }
493+ }
494+ submittedAnswer .setSubmittedTexts (sanitized );
495+ }
496+
372497 /**
373498 * Find StudentParticipation of the given quizExercise that was done by the given user
374499 *
0 commit comments