Skip to content

When a student starts/attempts a quiz, the quiz must be marked "given" and removed from their active quiz list (including when time runs out or they close the panel) #3

@sadiqhasanrupani

Description

@sadiqhasanrupani

Currently a student who starts a quiz can still see that quiz in their available/active quiz list if they close the quiz panel or their time expires. This causes duplication and allows re-opening attempts in some cases.
Behavior we want: once a student has attempted a quiz — whether they submitted, closed the panel, or the timer expired — the system must mark that quiz as given/submitted (or otherwise move it out of the active list) immediately and never show it again in the student's active quiz list.

This issue describes the desired behavior, steps to reproduce the bug, acceptance criteria, recommended backend changes and API considerations, and suggested tests.


Environment / Context

  • Project: Edugate (server)

  • Relevant endpoints:

    • POST /submit-quiz/submit-start-time-quiz (called when student starts)
    • POST /submit-quiz/submit-quiz-for-student (called when student finishes/submits)
  • DB: MySQL via Sequelize (and Drizzle available in repo)

  • Relevant models (approx): JoinQuiz / JoinClassroom / SubmittedQuiz / SubmitQuiz (server already records start/submit times)

  • Current frontend behavior: call submit-start-time-quiz on load of quiz page


Reproduction (current bug)

  1. Student navigates to quiz page and triggers POST /submit-quiz/submit-start-time-quiz (start).
  2. Student closes the quiz panel or lets the timer expire (without calling submit-quiz-for-student).
  3. Student returns to the quiz list (or refreshes).
  4. The same quiz still appears as "available" in the student's quiz list.

You may also reproduce by:

  • Starting a quiz (start-time recorded), then removing network connectivity and closing the panel; later the list still shows the quiz.

Expected behavior

  • After the start-time is recorded (student has begun the attempt), the server should mark the quiz as "in-progress" or "attempted" and the quiz should not be shown in the student's active/available quiz list.
  • If the student closes the panel or time runs out, the server should mark the quiz as given/submitted (with appropriate status: timed_out / closed / auto_submitted) and the quiz becomes invisible in the active list.
  • The server must treat start and submit operations idempotently and within a transaction to avoid race conditions.
  • Frontend must not rely solely on UI state — server must be the source of truth for filtering quizzes.

Acceptance criteria

  1. When a student calls submit-start-time-quiz, the server records startedAt and sets the student-quiz relation status to in_progress (or attempted) immediately.
  2. A quiz with status in_progress, timed_out, or submitted must be excluded from responses that return the student's available/active quizzes.
  3. When the timer expires (or student closes without explicit submit), the server will mark the attempt as timed_out (with endAt = timestamp) and create any necessary SubmittedQuiz record so it no longer appears as active.
  4. The submit-quiz-for-student endpoint must safely transition in_progress -> submitted and be idempotent.
  5. Race conditions handled: multiple concurrent calls (start/submit/timeout) should not produce duplicate or conflicting records.
  6. Add logging and metrics for start/submit/timeout events for debugging.
  7. Add unit/integration tests covering start → timeout, start → normal submit, start → client-close → server marks timed_out.

Recommended backend changes (high level)

DB / Model

Add or ensure the student-quiz relationship has a status column and timestamps:

student_quiz_attempts (
  id UUID PRIMARY KEY,
  student_id UUID,
  join_quiz_id UUID, -- reference to the quiz instance for that student
  status ENUM('pending','in_progress','timed_out','submitted') DEFAULT 'pending',
  started_at DATETIME NULL,
  ended_at DATETIME NULL,
  created_at DATETIME,
  updated_at DATETIME,
  -- any other metadata (score, attempts, reason etc.)
);
  • If such table already exists (e.g., join_quiz, join_classroom), confirm it contains equivalent status, startedAt, endedAt fields and update if missing.

API behavior

  1. POST /submit-quiz/submit-start-time-quiz

    • Current: inserts start time. Update to:

      • Look up student_quiz_attempt by student_id + join_quiz_id.
      • If record does not exist: create with status = in_progress, started_at = now.
      • If record exists and status is pending: set to in_progress.
      • If record exists and status is in_progress or submitted or timed_out, return an appropriate 4xx (403) with clarifying message (idempotent).
      • Use a DB transaction or INSERT ... ON DUPLICATE KEY UPDATE to avoid race conditions.
      • Return the new status.
  2. Auto-timeout / client-closed behavior

    • Two possible strategies:

      • Server-driven timeout (recommended): store started_at and the quiz duration; when listing active quizzes, server filters out attempts that have started_at + duration <= now and optionally auto-move them to timed_out (via background job or on-demand when student requests list).
      • Immediate move on client-close: when the client detects close/unmount, it may call POST /submit-quiz/submit-quiz-for-student marking endTime and status=timed_out. But clients can be unreliable — server must also check for timeouts.
    • Implement a scheduled job (cron or a cheap on-read check) to find in_progress attempts where started_at + quiz_duration < now and mark them timed_out and generate submitted record if needed.

    • For immediate UX, on every call to fetch the quiz list, run a small cleanup routine that transitions expired in_progress attempts to timed_out before returning list.

  3. POST /submit-quiz/submit-quiz-for-student

    • Ensure idempotency:

      • If status is already submitted or timed_out, return 409 or 200 with meaningful message; do not process again.
      • Wrap DB operations in a transaction: create SubmittedQuiz (answers, score), update attempt status to submitted, set ended_at.

Concurrency / transactional model

  • Use transactions for start → update, and submit → create & update.

  • When updating status, check previous status in the same transaction to prevent double processing:

    • e.g. UPDATE student_quiz_attempts SET status='submitted', ended_at=now() WHERE id = ? AND status = 'in_progress' and check affected rows. If zero rows affected, decide how to respond (already submitted/timed_out).

Filtering on quiz list

  • Modify the quiz list API (that returns available quizzes) to exclude any join_quiz entries where student_quiz_attempts.status IN ('in_progress', 'submitted', 'timed_out').
  • If unable to change schema, use join with submitted_quizzes to exclude those join_quiz ids.

Logging & Metrics

  • Log start/submit/timeout transitions with student_id, join_quiz_id, status, timestamps.
  • Add a metric (counter) for timeouts to monitor UX issues.

Example pseudo-code for submit-start-time-quiz

// Using Sequelize pseudo-code
async function submitStartTime(req, res) {
  const { joinQuizId } = req.body;
  const studentId = req.userId;
  const t = await sequelize.transaction();
  try {
    // lock the row or use upsert
    const [attempt, created] = await StudentQuizAttempt.findOrCreate({
      where: { student_id: studentId, join_quiz_id: joinQuizId },
      defaults: { status: 'in_progress', started_at: new Date() },
      transaction: t,
    });

    if (!created) {
      if (attempt.status === 'pending') {
        await attempt.update({ status: 'in_progress', started_at: new Date() }, { transaction: t });
      } else {
        // already in_progress, submitted, or timed_out -> respond with informative message
        await t.rollback();
        return res.status(403).json({ message: `Forbidden to give quiz again.` });
      }
    }

    await t.commit();
    return res.json({ message: 'Quiz started', status: 'in_progress' });
  } catch (err) {
    await t.rollback();
    throw err;
  }
}

Edge cases & considerations

  • Network unreliability: client may not call submit when user closes. Server-side timeout job required.
  • Multiple browser tabs: starting from two tabs should not create two attempts; enforce uniqueness and update existing attempt if needed.
  • Re-attempts: business rule — should students ever be allowed to reattempt? If yes, support explicit reattempt workflow (and track attempt counts). If not, enforce single attempt.
  • Partial answers: if you decide to auto-create a SubmittedQuiz record on timeout, decide what to store (empty answers vs. last known answers).
  • Time drift & timezone: use UTC timestamps and quiz duration stored in minutes/seconds.
  • Performance: cleaning up expired attempts on-read is acceptable for small scale; for larger scale use a scheduled worker.

Tests to add

  • Unit tests:

    • submit-start-time-quiz: new attempt created -> status = in_progress
    • submit-start-time-quiz: second start on same attempt -> 403 (idempotency)
    • submit-quiz-for-student: transitions in_progress -> submitted and creates SubmittedQuiz
  • Integration tests:

    • Start -> close (no submit) -> list API should not return the quiz after timeout job runs or immediate cleanup
    • Start -> client explicit close triggers submit -> list excludes quiz
    • Concurrent start + submit race: ensure only one final state, no double submission
  • E2E:

    • Real browser flow: start quiz, close, reopen list page -> quiz should not appear.

Migration / DB tasks

  • If student_quiz_attempts table does not exist, add it (see schema above).
  • If status or started_at fields are missing in existing table, add columns and backfill (default pending).
  • Add an index on (student_id, join_quiz_id) to make lookups fast.

Priority & Impact

  • Priority: High — this affects correctness of quiz workflow and UX (students seeing quizzes they already attempted).
  • Risk: Moderate — touching quiz lifecycle and DB; must be thoroughly tested, particularly for concurrency and timeouts.

Notes / Follow-ups

  • Decide policy for auto-submission content on timeout (save partial answers? save none?). Document UX expectations.
  • If the frontend should proactively call an endpoint on unmount to submit partial answers, we can add that, but server must not rely solely on client behavior.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions