Skip to content

Commit 0c175e2

Browse files
Phase 3: Assignments & Grading Complete (#812)
* Phase 3: Assignments & Grading complete * Update src/pages/api/lms/submissions/[submissionId]/grade.ts Co-authored-by: Copilot <[email protected]> * Update src/pages/api/lms/submissions/index.ts Co-authored-by: Copilot <[email protected]> * fix: add name field to AuthenticatedRequest type --------- Co-authored-by: Copilot <[email protected]>
1 parent 2bcdb32 commit 0c175e2

File tree

5 files changed

+451
-0
lines changed

5 files changed

+451
-0
lines changed

src/lib/rbac.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type Role = 'STUDENT' | 'INSTRUCTOR' | 'ADMIN' | 'MENTOR';
77
export interface AuthenticatedRequest extends NextApiRequest {
88
user?: {
99
id: string;
10+
name: string | null;
1011
email: string;
1112
role: Role;
1213
};
@@ -33,6 +34,7 @@ export function requireAuth(
3334

3435
req.user = {
3536
id: session.user.id,
37+
name: session.user.name || null,
3638
email: session.user.email || '',
3739
role: (session.user.role as Role) || 'STUDENT',
3840
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { NextApiResponse } from 'next';
2+
import { requireRole, AuthenticatedRequest } from '@/lib/rbac';
3+
import prisma from '@/lib/prisma';
4+
5+
/**
6+
* PUT /api/lms/submissions/[submissionId]/grade
7+
*
8+
* Grade a submission. Updates score, feedback, status, and gradedAt timestamp.
9+
* Requires INSTRUCTOR, ADMIN, or MENTOR role.
10+
*
11+
* Request body:
12+
* {
13+
* score: number (required, 0 to assignment.maxPoints),
14+
* feedback?: string,
15+
* status?: 'GRADED' | 'NEEDS_REVISION' (default 'GRADED')
16+
* }
17+
*
18+
* Response:
19+
* {
20+
* submission: {...},
21+
* message: string
22+
* }
23+
*/
24+
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
25+
if (req.method !== 'PUT') {
26+
return res.status(405).json({ error: 'Method not allowed' });
27+
}
28+
29+
try {
30+
const { submissionId } = req.query;
31+
const { score, feedback, status = 'GRADED' } = req.body;
32+
33+
// Validate submissionId
34+
if (!submissionId || typeof submissionId !== 'string') {
35+
return res.status(400).json({ error: 'Invalid submission ID' });
36+
}
37+
38+
// Validate score is provided
39+
if (score === undefined || score === null) {
40+
return res.status(400).json({ error: 'Score is required' });
41+
}
42+
43+
// Validate score is a number
44+
const scoreNum = Number(score);
45+
if (isNaN(scoreNum)) {
46+
return res.status(400).json({ error: 'Score must be a number' });
47+
}
48+
49+
// Validate status
50+
if (status && !['GRADED', 'NEEDS_REVISION'].includes(status)) {
51+
return res.status(400).json({
52+
error: 'Invalid status. Must be GRADED or NEEDS_REVISION'
53+
});
54+
}
55+
56+
// Fetch submission with assignment data
57+
const submission = await prisma.submission.findUnique({
58+
where: { id: submissionId },
59+
include: {
60+
assignment: {
61+
select: {
62+
id: true,
63+
title: true,
64+
maxPoints: true,
65+
courseId: true,
66+
},
67+
},
68+
user: {
69+
select: {
70+
id: true,
71+
name: true,
72+
email: true,
73+
},
74+
},
75+
},
76+
});
77+
78+
if (!submission) {
79+
return res.status(404).json({ error: 'Submission not found' });
80+
}
81+
82+
// Validate score is within valid range
83+
if (scoreNum < 0) {
84+
return res.status(400).json({ error: 'Score cannot be negative' });
85+
}
86+
87+
if (scoreNum > submission.assignment.maxPoints) {
88+
return res.status(400).json({
89+
error: `Score cannot exceed maximum points (${submission.assignment.maxPoints})`
90+
});
91+
}
92+
93+
// Check if submission is in a valid state for grading
94+
if (submission.status === 'DRAFT') {
95+
return res.status(400).json({
96+
error: 'Cannot grade a draft submission'
97+
});
98+
}
99+
100+
// Update submission with grade
101+
const gradedSubmission = await prisma.submission.update({
102+
where: { id: submissionId },
103+
data: {
104+
score: scoreNum,
105+
feedback: feedback || submission.feedback,
106+
status,
107+
gradedAt: new Date(),
108+
},
109+
include: {
110+
assignment: {
111+
select: {
112+
id: true,
113+
title: true,
114+
description: true,
115+
maxPoints: true,
116+
dueDate: true,
117+
type: true,
118+
course: {
119+
select: {
120+
id: true,
121+
title: true,
122+
},
123+
},
124+
},
125+
},
126+
user: {
127+
select: {
128+
id: true,
129+
name: true,
130+
email: true,
131+
image: true,
132+
},
133+
},
134+
},
135+
});
136+
137+
// Calculate percentage
138+
const percentage = (scoreNum / submission.assignment.maxPoints) * 100;
139+
140+
res.status(200).json({
141+
submission: {
142+
...gradedSubmission,
143+
percentage: Math.round(percentage * 10) / 10, // Round to 1 decimal
144+
},
145+
message: 'Submission graded successfully',
146+
gradedBy: {
147+
id: req.user!.id,
148+
name: req.user!.name,
149+
role: req.user!.role,
150+
},
151+
});
152+
} catch (error) {
153+
console.error('Error grading submission:', error);
154+
res.status(500).json({ error: 'Failed to grade submission' });
155+
}
156+
}
157+
158+
// Require INSTRUCTOR, ADMIN, or MENTOR role
159+
export default requireRole(['INSTRUCTOR', 'ADMIN', 'MENTOR'])(handler);
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { NextApiResponse } from 'next';
2+
import { requireAuth, AuthenticatedRequest } from '@/lib/rbac';
3+
import prisma from '@/lib/prisma';
4+
5+
/**
6+
* POST /api/lms/submissions
7+
*
8+
* Submit an assignment. Students can create or update their submissions.
9+
* Only one submission per user per assignment (enforced by unique constraint).
10+
*
11+
* Request body:
12+
* {
13+
* assignmentId: string,
14+
* githubUrl?: string,
15+
* liveUrl?: string,
16+
* notes?: string,
17+
* files?: string (JSON array of file URLs)
18+
* }
19+
*
20+
* Response:
21+
* {
22+
* submission: {...},
23+
* message: string
24+
* }
25+
*/
26+
export default requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => {
27+
if (req.method !== 'POST') {
28+
return res.status(405).json({ error: 'Method not allowed' });
29+
}
30+
31+
try {
32+
const { assignmentId, githubUrl, liveUrl, notes, files } = req.body;
33+
const userId = req.user!.id;
34+
35+
// Validate required fields
36+
if (!assignmentId) {
37+
return res.status(400).json({ error: 'Assignment ID is required' });
38+
}
39+
40+
// Validate assignment exists
41+
const assignment = await prisma.assignment.findUnique({
42+
where: { id: assignmentId },
43+
include: {
44+
course: {
45+
include: {
46+
enrollments: {
47+
where: {
48+
userId,
49+
status: 'ACTIVE',
50+
},
51+
},
52+
},
53+
},
54+
},
55+
});
56+
57+
if (!assignment) {
58+
return res.status(404).json({ error: 'Assignment not found' });
59+
}
60+
61+
// Verify user is enrolled in the course
62+
if (assignment.course.enrollments.length === 0) {
63+
return res.status(403).json({
64+
error: 'You must be enrolled in the course to submit assignments'
65+
});
66+
}
67+
68+
// Validate URL formats if provided
69+
const urlRegex = /^https?:\/\/.+/;
70+
if (githubUrl && !urlRegex.test(githubUrl)) {
71+
return res.status(400).json({ error: 'Invalid GitHub URL format' });
72+
}
73+
if (liveUrl && !urlRegex.test(liveUrl)) {
74+
return res.status(400).json({ error: 'Invalid live demo URL format' });
75+
}
76+
77+
// Check assignment requirements
78+
if (assignment.githubRepo && !githubUrl) {
79+
return res.status(400).json({
80+
error: 'GitHub repository URL is required for this assignment'
81+
});
82+
}
83+
if (assignment.liveDemo && !liveUrl) {
84+
return res.status(400).json({
85+
error: 'Live demo URL is required for this assignment'
86+
});
87+
}
88+
89+
// Check for existing submission
90+
const existingSubmission = await prisma.submission.findUnique({
91+
where: {
92+
userId_assignmentId: {
93+
userId,
94+
assignmentId,
95+
},
96+
},
97+
});
98+
99+
let submission;
100+
101+
if (existingSubmission) {
102+
// Update existing submission (allow resubmission before grading)
103+
if (existingSubmission.status === 'GRADED') {
104+
return res.status(400).json({
105+
error: 'Cannot resubmit a graded assignment'
106+
});
107+
}
108+
109+
submission = await prisma.submission.update({
110+
where: {
111+
id: existingSubmission.id,
112+
},
113+
data: {
114+
githubUrl: githubUrl ?? existingSubmission.githubUrl,
115+
liveUrl: liveUrl ?? existingSubmission.liveUrl,
116+
notes: notes ?? existingSubmission.notes,
117+
files: files ?? existingSubmission.files,
118+
status: 'SUBMITTED',
119+
submittedAt: new Date(),
120+
},
121+
include: {
122+
assignment: {
123+
select: {
124+
id: true,
125+
title: true,
126+
maxPoints: true,
127+
dueDate: true,
128+
},
129+
},
130+
user: {
131+
select: {
132+
id: true,
133+
name: true,
134+
email: true,
135+
},
136+
},
137+
},
138+
});
139+
} else {
140+
// Create new submission
141+
submission = await prisma.submission.create({
142+
data: {
143+
userId,
144+
assignmentId,
145+
githubUrl,
146+
liveUrl,
147+
notes,
148+
files,
149+
status: 'SUBMITTED',
150+
},
151+
include: {
152+
assignment: {
153+
select: {
154+
id: true,
155+
title: true,
156+
maxPoints: true,
157+
dueDate: true,
158+
},
159+
},
160+
user: {
161+
select: {
162+
id: true,
163+
name: true,
164+
email: true,
165+
},
166+
},
167+
},
168+
});
169+
}
170+
171+
res.status(existingSubmission ? 200 : 201).json({
172+
submission,
173+
message: existingSubmission
174+
? 'Submission updated successfully'
175+
: 'Submission created successfully',
176+
});
177+
} catch (error) {
178+
console.error('Error creating/updating submission:', error);
179+
res.status(500).json({ error: 'Failed to submit assignment' });
180+
}
181+
});

0 commit comments

Comments
 (0)