Skip to content
Merged
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
101 changes: 44 additions & 57 deletions app/api/exercises/[id]/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,34 @@ export async function GET(
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const userId = session.user.id;
const exerciseId = params.id;

// Look up or create the session for this exercise + user
// Check exercise-level start_time BEFORE creating a session
// This way participants who haven't started yet are blocked at the exercise level
const exerciseRows = await sql`
SELECT start_time, end_time, duration_limit, question_count
FROM exercises WHERE id = ${exerciseId} LIMIT 1
`;
if (exerciseRows.length === 0) {
return NextResponse.json({ error: 'Exercise not found' }, { status: 404 });
}
const exercise = exerciseRows[0];

if (exercise.start_time && new Date() < new Date(exercise.start_time as string)) {
return NextResponse.json(
{ error: 'Session not yet open', opens_at: exercise.start_time },
{ status: 423 }
);
}

// Look up or create the session
let rows = await sql`
SELECT s.id, s.start_time, s.end_time, s.duration_limit,
SELECT s.id, s.end_time, s.duration_limit,
s.started_at, s.closed_at, s.current_question_index,
e.question_count
FROM sessions s
Expand All @@ -30,69 +47,50 @@ export async function GET(
`;

if (rows.length === 0) {
// Create a new session, inheriting timing from the exercise
// Create session — inherit end_time and duration_limit but NOT start_time
// (start_time is checked at the exercise level above)
const created = await sql`
INSERT INTO sessions (exercise_id, user_id, started_at, current_question_index, start_time, end_time, duration_limit)
INSERT INTO sessions (exercise_id, user_id, started_at, current_question_index, end_time, duration_limit)
SELECT
${exerciseId},
${userId},
now(),
0,
e.start_time,
e.end_time,
e.duration_limit
FROM exercises e
WHERE e.id = ${exerciseId}
RETURNING id, start_time, end_time, duration_limit,
started_at, closed_at, current_question_index
`;

const exerciseRows = await sql`
SELECT question_count FROM exercises WHERE id = ${exerciseId}
RETURNING id, end_time, duration_limit, started_at, closed_at, current_question_index
`;

rows = created.map((r) => ({
...r,
question_count: exerciseRows[0]?.question_count ?? 0,
question_count: exercise.question_count ?? 0,
}));
}

const row = rows[0];

// 423 if start_time is set and now() < start_time
if (row.start_time && new Date() < new Date(row.start_time as string)) {
return NextResponse.json(
{ error: 'Session not yet open', opens_at: row.start_time },
{ status: 423 }
);
}

// 410 if session is closed
if (row.closed_at) {
return NextResponse.json({ error: 'Session closed' }, { status: 410 });
}

const now = new Date();

// Auto-close if end_time has passed
if (row.end_time && now > new Date(row.end_time as string)) {
await sql`
UPDATE sessions SET closed_at = now()
WHERE id = ${row.id} AND closed_at IS NULL
`;
// Auto-close if end_time has passed (check both session and exercise end_time)
const effectiveEndTime = (row.end_time || exercise.end_time) as string | null;
if (effectiveEndTime && now > new Date(effectiveEndTime)) {
await sql`UPDATE sessions SET closed_at = now() WHERE id = ${row.id} AND closed_at IS NULL`;
return NextResponse.json({ error: 'Session closed' }, { status: 410 });
}

// Auto-close if duration_limit has been exceeded
// Auto-close if duration_limit exceeded
if (row.duration_limit && row.started_at) {
const durationSeconds = parseIntervalToSeconds(row.duration_limit);
const startedAt = new Date(row.started_at as string).getTime();
const expiresAt = startedAt + durationSeconds * 1000;
if (now.getTime() > expiresAt) {
await sql`
UPDATE sessions SET closed_at = now()
WHERE id = ${row.id} AND closed_at IS NULL
`;
if (now.getTime() > startedAt + durationSeconds * 1000) {
await sql`UPDATE sessions SET closed_at = now() WHERE id = ${row.id} AND closed_at IS NULL`;
return NextResponse.json({ error: 'Session closed' }, { status: 410 });
}
}
Expand All @@ -103,41 +101,30 @@ export async function GET(
if (row.duration_limit && row.started_at) {
const durationSeconds = parseIntervalToSeconds(row.duration_limit);
const startedAt = new Date(row.started_at as string).getTime();
const expiresAt = startedAt + durationSeconds * 1000;
remainingSeconds = Math.floor((expiresAt - Date.now()) / 1000);
remainingSeconds = Math.floor((startedAt + durationSeconds * 1000 - Date.now()) / 1000);
}

if (row.end_time) {
const fromEndTime = Math.floor(
(new Date(row.end_time as string).getTime() - Date.now()) / 1000
);
if (effectiveEndTime) {
const fromEndTime = Math.floor((new Date(effectiveEndTime).getTime() - Date.now()) / 1000);
remainingSeconds = remainingSeconds === null ? fromEndTime : Math.min(remainingSeconds, fromEndTime);
}

const warningLowTime =
remainingSeconds !== null && remainingSeconds < 300;

// Query per-question submission statuses
const submissions = await sql`
SELECT question_index, is_final,
(response_text IS NOT NULL AND response_text <> '') AS has_draft
FROM submissions
WHERE session_id = ${row.id}
FROM submissions WHERE session_id = ${row.id}
`;

const questionStatuses = submissions.map((s) => ({
question_index: s.question_index,
has_draft: Boolean(s.has_draft),
is_final: Boolean(s.is_final),
}));

return NextResponse.json({
session_id: row.id,
current_question_index: row.current_question_index,
question_count: row.question_count,
current_question_index: row.current_question_index as number,
question_count: row.question_count as number,
remaining_seconds: remainingSeconds,
warning_low_time: warningLowTime,
question_statuses: questionStatuses,
warning_low_time: remainingSeconds !== null && remainingSeconds < 300,
question_statuses: submissions.map((s) => ({
question_index: s.question_index,
has_draft: Boolean(s.has_draft),
is_final: Boolean(s.is_final),
})),
});
}

1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
--bg2: #0c0f14;
--bg3: #111520;
--bg4: #161b28;
--bg5: #535353;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.1);
--accent: #6366f1;
Expand Down
5 changes: 4 additions & 1 deletion app/instructor/exercises/[id]/ExerciseManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ export default function ExerciseManager({ exercise: initial, sessions, assignedU
</div>
</div>
<button
onClick={() => patch({ start_time: startTime || null, end_time: endTime || null, duration_limit: durationLimit || null })}
onClick={() => {
const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
patch({ start_time: toUTC(startTime), end_time: toUTC(endTime), duration_limit: durationLimit || null });
}}
disabled={saving}
className="btn btn-primary"
>
Expand Down
6 changes: 4 additions & 2 deletions app/participant/session/[id]/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,17 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) {
)}

{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<ProgressBar
currentIndex={sessionState.current_question_index}
viewingIndex={activeIndex}
questionCount={sessionState.question_count}
questionStatuses={sessionState.question_statuses}
onNavigate={(i) => setViewingIndex(i === sessionState.current_question_index ? null : i)}
/>
<TimerDisplay remainingSeconds={displayRemainingSeconds} warningLowTime={displayRemainingSeconds !== null && displayRemainingSeconds < 300} />
<div style={{ flexShrink: 0 }}>
<TimerDisplay remainingSeconds={displayRemainingSeconds} warningLowTime={displayRemainingSeconds !== null && displayRemainingSeconds < 300} />
</div>
</div>

{/* Question */}
Expand Down
Loading