-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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-quizon load of quiz page
Reproduction (current bug)
- Student navigates to quiz page and triggers
POST /submit-quiz/submit-start-time-quiz(start). - Student closes the quiz panel or lets the timer expire (without calling
submit-quiz-for-student). - Student returns to the quiz list (or refreshes).
- 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
startandsubmitoperations 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
- When a student calls
submit-start-time-quiz, the server recordsstartedAtand sets the student-quiz relation status toin_progress(orattempted) immediately. - A quiz with status
in_progress,timed_out, orsubmittedmust be excluded from responses that return the student's available/active quizzes. - When the timer expires (or student closes without explicit submit), the server will mark the attempt as
timed_out(withendAt= timestamp) and create any necessarySubmittedQuizrecord so it no longer appears as active. - The
submit-quiz-for-studentendpoint must safely transitionin_progress->submittedand be idempotent. - Race conditions handled: multiple concurrent calls (start/submit/timeout) should not produce duplicate or conflicting records.
- Add logging and metrics for start/submit/timeout events for debugging.
- 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 equivalentstatus,startedAt,endedAtfields and update if missing.
API behavior
-
POST /submit-quiz/submit-start-time-quiz-
Current: inserts start time. Update to:
- Look up
student_quiz_attemptbystudent_id+join_quiz_id. - If record does not exist: create with
status = in_progress,started_at = now. - If record exists and
statusispending: set toin_progress. - If record exists and
statusisin_progressorsubmittedortimed_out, return an appropriate 4xx (403) with clarifying message (idempotent). - Use a DB transaction or
INSERT ... ON DUPLICATE KEY UPDATEto avoid race conditions. - Return the new
status.
- Look up
-
-
Auto-timeout / client-closed behavior
-
Two possible strategies:
- Server-driven timeout (recommended): store
started_atand the quiz duration; when listing active quizzes, server filters out attempts that havestarted_at + duration <= nowand optionally auto-move them totimed_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-studentmarkingendTimeandstatus=timed_out. But clients can be unreliable — server must also check for timeouts.
- Server-driven timeout (recommended): store
-
Implement a scheduled job (cron or a cheap on-read check) to find
in_progressattempts wherestarted_at + quiz_duration< now and mark themtimed_outand generatesubmittedrecord if needed. -
For immediate UX, on every call to fetch the quiz list, run a small cleanup routine that transitions expired
in_progressattempts totimed_outbefore returning list.
-
-
POST /submit-quiz/submit-quiz-for-student-
Ensure idempotency:
- If status is already
submittedortimed_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 tosubmitted, setended_at.
- If status is already
-
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).
- e.g.
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_quizzesto 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
SubmittedQuizrecord 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_progresssubmit-start-time-quiz: second start on same attempt -> 403 (idempotency)submit-quiz-for-student: transitionsin_progress->submittedand createsSubmittedQuiz
-
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_attemptstable does not exist, add it (see schema above). - If
statusorstarted_atfields are missing in existing table, add columns and backfill (defaultpending). - 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.