diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 0910fc140af..0732b7609da 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -35,14 +35,16 @@ const CLASSROOM_TTL_SECONDS = CLASSROOM_TTL_DAYS * 24 * 60 * 60; // Session and membership TTL matches classroom TTL const SESSION_TTL_SECONDS = CLASSROOM_TTL_SECONDS; // Rate limiting for join endpoint (per IP) -const JOIN_RATE_LIMIT_WINDOW_SECONDS = 60; -const JOIN_RATE_LIMIT_MAX_ATTEMPTS = 50; +const JOIN_RATE_LIMIT_WINDOW_SECONDS = parseInt(process.env.JOIN_RATE_LIMIT_WINDOW_SECONDS || '60', 10); +const JOIN_RATE_LIMIT_MAX_ATTEMPTS = parseInt(process.env.JOIN_RATE_LIMIT_MAX_ATTEMPTS || '50', 10); const JOIN_CODE_REGEX = new RegExp(`^[${JOIN_CODE_CHARS}]{${JOIN_CODE_LENGTH}}$`); +// Session activity TTL — determines "seated" status for teachers (default 1 hour) +const SESSION_ACTIVE_TTL_SECONDS = parseInt(process.env.SESSION_ACTIVE_TTL_SECONDS || '3600', 10); // Submission config (TTL matches classroom TTL) const SUBMISSION_TTL_SECONDS = CLASSROOM_TTL_SECONDS; const MAX_PROJECT_NAME_LENGTH = 100; -const PRESIGNED_URL_UPLOAD_EXPIRY = 15 * 60; // 15 minutes -const PRESIGNED_URL_DOWNLOAD_EXPIRY = 60 * 60; // 1 hour +const PRESIGNED_URL_UPLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_UPLOAD_EXPIRY || '900', 10); // default 15 minutes +const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10); // default 1 hour const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_SCREENSHOT_COUNT = 20; const MAX_TEACHER_COMMENT_LENGTH = 500; @@ -420,6 +422,7 @@ async function handleJoinClassroom(sourceIp: string, body: Record { // verifySessionToken will throw AuthError if invalid - await verifySessionToken(sessionToken); - return { statusCode: 200, body: JSON.stringify({ valid: true }) }; + const session = await verifySessionToken(sessionToken); + + // Update lastActiveAt and extend TTL on each verify call + const now = new Date().toISOString(); + await docClient.send(new UpdateCommand({ + TableName: MEMBERSHIPS_TABLE, + Key: { classroomId: session.classroomId, memberId: session.memberId }, + UpdateExpression: 'SET lastActiveAt = :now, ttl = :ttl', + ExpressionAttributeValues: { + ':now': now, + ':ttl': Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, + }, + })); + + // Look up latest submission for this member + let submission: Record | null = null; + const subResult = await docClient.send(new QueryCommand({ + TableName: SUBMISSIONS_TABLE, + IndexName: 'classroomId-memberId-index', + KeyConditionExpression: 'classroomId = :cid AND memberId = :mid', + ExpressionAttributeValues: { + ':cid': session.classroomId, + ':mid': session.memberId, + }, + ScanIndexForward: false, + Limit: 1, + })); + if (subResult.Items && subResult.Items.length > 0) { + const item = subResult.Items[0]; + submission = { + status: item.status, + submittedAt: item.submittedAt, + teacherComment: item.teacherComment || null, + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ valid: true, submission }), + }; } // --- Main handler --- @@ -957,11 +999,27 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise - {classroomState.submissionStatus === 'submitted' ? ( + {classroomState.submissionStatus === 'returned' ? ( + + {'↩ '} + + {classroomState.lastSubmittedAt && ( + {` (${new Date(classroomState.lastSubmittedAt).toLocaleTimeString()})`} + )} + + ) : classroomState.submissionStatus === 'submitted' ? ( {'✓ '} )} + + {classroomState.submissionStatus === 'returned' && teacherComment && ( +
+ + + + + {teacherComment} + +
+ )}
); })} @@ -1208,18 +1263,36 @@ const TeacherClassDetail = ({
) : ( - +
+ + +
)} @@ -1255,6 +1328,24 @@ const TeacherClassDetail = ({ {new Date(selectedMemberData.submittedAt).toLocaleTimeString()} )} + + {selectedMemberData.isSeated ? ( + + ) : ( + + )} +