@studious-lms/server 1.2.26 → 1.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { createTRPCRouter, protectedProcedure } from "src/trpc";
3
+ import { z } from "zod";
4
+ import { prisma } from "src/lib/prisma";
5
+
6
+ export const worksheetRouter = createTRPCRouter({
7
+ // Get a single worksheet with all questions
8
+ getWorksheet: protectedProcedure
9
+ .input(z.object({
10
+ worksheetId: z.string(),
11
+ }))
12
+ .query(async ({ ctx, input }) => {
13
+ const { worksheetId } = input;
14
+
15
+ const worksheet = await prisma.worksheet.findUnique({
16
+ where: { id: worksheetId },
17
+ include: {
18
+ questions: {
19
+ orderBy: { createdAt: 'asc' },
20
+ },
21
+ class: true,
22
+ },
23
+ });
24
+
25
+ if (!worksheet) {
26
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet not found' });
27
+ }
28
+
29
+ return worksheet;
30
+ }),
31
+
32
+ // List all worksheets for a class
33
+ listWorksheets: protectedProcedure
34
+ .input(z.object({
35
+ classId: z.string(),
36
+ }))
37
+ .query(async ({ ctx, input }) => {
38
+ const { classId } = input;
39
+
40
+ const worksheets = await prisma.worksheet.findMany({
41
+ where: { classId },
42
+ include: {
43
+ questions: {
44
+ select: { id: true },
45
+ },
46
+ },
47
+ orderBy: { createdAt: 'desc' },
48
+ });
49
+
50
+ return worksheets.map(worksheet => ({
51
+ ...worksheet,
52
+ questionCount: worksheet.questions.length,
53
+ }));
54
+ }),
55
+
56
+ // Update worksheet metadata
57
+ updateWorksheet: protectedProcedure
58
+ .input(z.object({
59
+ worksheetId: z.string(),
60
+ name: z.string().optional(),
61
+ }))
62
+ .mutation(async ({ ctx, input }) => {
63
+ const { worksheetId, name } = input;
64
+
65
+ const worksheet = await prisma.worksheet.update({
66
+ where: { id: worksheetId },
67
+ data: {
68
+ ...(name !== undefined && { name }),
69
+ },
70
+ });
71
+
72
+ return worksheet;
73
+ }),
74
+
75
+ // Delete a worksheet
76
+ deleteWorksheet: protectedProcedure
77
+ .input(z.object({
78
+ worksheetId: z.string(),
79
+ }))
80
+ .mutation(async ({ ctx, input }) => {
81
+ const { worksheetId } = input;
82
+
83
+ // This will cascade delete all questions and responses
84
+ const deletedWorksheet = await prisma.worksheet.delete({
85
+ where: { id: worksheetId },
86
+ });
87
+
88
+ return deletedWorksheet;
89
+ }),
90
+
91
+ create: protectedProcedure
92
+ .input(z.object({
93
+ classId: z.string(),
94
+ name: z.string(),
95
+ }))
96
+ .mutation(async ({ ctx, input }) => {
97
+ const { classId, name } = input;
98
+
99
+ // Create the worksheet
100
+ const worksheet = await prisma.worksheet.create({
101
+ data: {
102
+ name,
103
+ classId,
104
+ },
105
+ });
106
+
107
+ return worksheet;
108
+ }),
109
+ addQuestion: protectedProcedure
110
+ .input(z.object({
111
+ worksheetId: z.string(),
112
+ question: z.string(),
113
+ answer: z.string(),
114
+ markScheme: z.any().optional(), // JSON field
115
+ type: z.enum(['MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY']),
116
+ }))
117
+ .mutation(async ({ ctx, input }) => {
118
+ const { worksheetId, question, answer, markScheme, type } = input;
119
+
120
+ const worksheet = await prisma.worksheet.findUnique({
121
+ where: { id: worksheetId },
122
+ });
123
+
124
+ if (!worksheet) {
125
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet not found' });
126
+ }
127
+
128
+ const newQuestion = await prisma.worksheetQuestion.create({
129
+ data: {
130
+ worksheetId,
131
+ type,
132
+ question,
133
+ answer,
134
+ markScheme,
135
+ },
136
+ });
137
+
138
+ return newQuestion;
139
+ }),
140
+ updateQuestion: protectedProcedure
141
+ .input(z.object({
142
+ worksheetId: z.string(),
143
+ questionId: z.string(),
144
+ question: z.string().optional(),
145
+ answer: z.string().optional(),
146
+ markScheme: z.any().optional(), // JSON field
147
+ type: z.enum(['MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY']).optional(),
148
+ }))
149
+ .mutation(async ({ ctx, input }) => {
150
+ const { worksheetId, questionId, question, answer, markScheme, type } = input;
151
+
152
+ const worksheet = await prisma.worksheet.findUnique({
153
+ where: { id: worksheetId },
154
+ });
155
+
156
+ if (!worksheet) {
157
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet not found' });
158
+ }
159
+
160
+ const updatedQuestion = await prisma.worksheetQuestion.update({
161
+ where: { id: questionId },
162
+ data: {
163
+ ...(question !== undefined && { question }),
164
+ ...(answer !== undefined && { answer }),
165
+ ...(markScheme !== undefined && { markScheme }),
166
+ ...(type !== undefined && { type }),
167
+ },
168
+ });
169
+
170
+ return updatedQuestion;
171
+ }),
172
+ deleteQuestion: protectedProcedure
173
+ .input(z.object({
174
+ worksheetId: z.string(),
175
+ questionId: z.string(),
176
+ }))
177
+ .mutation(async ({ ctx, input }) => {
178
+ const { worksheetId, questionId } = input;
179
+
180
+ const worksheet = await prisma.worksheet.findUnique({
181
+ where: { id: worksheetId },
182
+ });
183
+
184
+ if (!worksheet) {
185
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet not found' });
186
+ }
187
+
188
+ const deletedQuestion = await prisma.worksheetQuestion.delete({
189
+ where: { id: questionId },
190
+ });
191
+
192
+ return deletedQuestion;
193
+ }),
194
+
195
+ getWorksheetSubmission: protectedProcedure
196
+ .input(z.object({
197
+ worksheetId: z.string(),
198
+ submissionId: z.string(),
199
+ }))
200
+ .query(async ({ ctx, input }) => {
201
+ const { worksheetId, submissionId } = input;
202
+
203
+ const submission = await prisma.submission.findUnique({
204
+ where: { id: submissionId },
205
+ });
206
+
207
+ if (!submission) {
208
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Submission not found' });
209
+ }
210
+
211
+ const worksheetResponses = await prisma.studentWorksheetResponse.findMany({
212
+ where: { submissionId },
213
+ include: {
214
+ responses: true,
215
+ },
216
+ });
217
+
218
+ return worksheetResponses;
219
+ }),
220
+ answerQuestion: protectedProcedure
221
+ .input(z.object({
222
+ worksheetResponseId: z.string(),
223
+ questionId: z.string(),
224
+ response: z.string(),
225
+ }))
226
+ .mutation(async ({ ctx, input }) => {
227
+ const { worksheetResponseId, questionId, response } = input;
228
+
229
+ const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
230
+ where: { id: worksheetResponseId },
231
+ include: {
232
+ responses: {
233
+ where: { questionId },
234
+ },
235
+ },
236
+ });
237
+
238
+ if (!worksheetResponse) {
239
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet response not found' });
240
+ }
241
+
242
+ // Check if a response already exists for this question
243
+ const existingResponse = worksheetResponse.responses[0];
244
+
245
+ if (existingResponse) {
246
+ // Update existing response
247
+ await prisma.studentQuestionProgress.update({
248
+ where: { id: existingResponse.id },
249
+ data: { response },
250
+ });
251
+ } else {
252
+ // Create new response
253
+ await prisma.studentQuestionProgress.create({
254
+ data: {
255
+ studentId: worksheetResponse.studentId,
256
+ questionId,
257
+ response,
258
+ studentWorksheetResponseId: worksheetResponseId,
259
+ },
260
+ });
261
+ }
262
+
263
+ // Return the updated worksheet response with all responses
264
+ const updatedWorksheetResponse = await prisma.studentWorksheetResponse.findUnique({
265
+ where: { id: worksheetResponseId },
266
+ include: {
267
+ responses: true,
268
+ },
269
+ });
270
+
271
+ return updatedWorksheetResponse;
272
+ }),
273
+ submitWorksheet: protectedProcedure
274
+ .input(z.object({
275
+ worksheetResponseId: z.string(),
276
+ }))
277
+ .mutation(async ({ ctx, input }) => {
278
+ const { worksheetResponseId } = input;
279
+
280
+ const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
281
+ where: { id: worksheetResponseId },
282
+ include: {
283
+ worksheet: {
284
+ include: {
285
+ questions: true,
286
+ },
287
+ },
288
+ responses: true,
289
+ },
290
+ });
291
+
292
+ if (!worksheetResponse) {
293
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Worksheet response not found' });
294
+ }
295
+
296
+ if (worksheetResponse.submitted) {
297
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Worksheet already submitted' });
298
+ }
299
+
300
+ // Mark worksheet as submitted
301
+ const submittedWorksheet = await prisma.studentWorksheetResponse.update({
302
+ where: { id: worksheetResponseId },
303
+ data: {
304
+ submitted: true,
305
+ submittedAt: new Date(),
306
+ },
307
+ include: {
308
+ responses: true,
309
+ },
310
+ });
311
+
312
+ // TODO: Implement AI grading here
313
+ // For now, we'll just mark all answers as pending review
314
+ // You could integrate with an AI service to auto-grade certain question types
315
+
316
+ return submittedWorksheet;
317
+ }),
318
+
319
+ // Get or create a student's worksheet response
320
+ getOrCreateWorksheetResponse: protectedProcedure
321
+ .input(z.object({
322
+ worksheetId: z.string(),
323
+ studentId: z.string(),
324
+ }))
325
+ .mutation(async ({ ctx, input }) => {
326
+ const { worksheetId, studentId } = input;
327
+
328
+ // Try to find existing response
329
+ let worksheetResponse = await prisma.studentWorksheetResponse.findFirst({
330
+ where: {
331
+ worksheetId,
332
+ studentId,
333
+ submitted: false, // Only get unsubmitted responses
334
+ },
335
+ include: {
336
+ responses: {
337
+ include: {
338
+ question: true,
339
+ },
340
+ },
341
+ },
342
+ });
343
+
344
+ // Create new response if none exists
345
+ if (!worksheetResponse) {
346
+ worksheetResponse = await prisma.studentWorksheetResponse.create({
347
+ data: {
348
+ worksheetId,
349
+ studentId,
350
+ },
351
+ include: {
352
+ responses: {
353
+ include: {
354
+ question: true,
355
+ },
356
+ },
357
+ },
358
+ });
359
+ }
360
+
361
+ return worksheetResponse;
362
+ }),
363
+
364
+ // Get all student responses for a worksheet (teacher view)
365
+ getWorksheetResponses: protectedProcedure
366
+ .input(z.object({
367
+ worksheetId: z.string(),
368
+ }))
369
+ .query(async ({ ctx, input }) => {
370
+ const { worksheetId } = input;
371
+
372
+ const responses = await prisma.studentWorksheetResponse.findMany({
373
+ where: { worksheetId },
374
+ include: {
375
+ student: {
376
+ select: {
377
+ id: true,
378
+ username: true,
379
+ profile: {
380
+ select: {
381
+ displayName: true,
382
+ profilePicture: true,
383
+ },
384
+ },
385
+ },
386
+ },
387
+ responses: {
388
+ include: {
389
+ question: true,
390
+ },
391
+ },
392
+ },
393
+ orderBy: { submittedAt: 'desc' },
394
+ });
395
+
396
+ return responses;
397
+ }),
398
+
399
+ // Grade a student's answer
400
+ gradeAnswer: protectedProcedure
401
+ .input(z.object({
402
+ responseId: z.string(), // StudentQuestionProgress ID
403
+ isCorrect: z.boolean(),
404
+ feedback: z.string().optional(),
405
+ }))
406
+ .mutation(async ({ ctx, input }) => {
407
+ const { responseId, isCorrect, feedback } = input;
408
+
409
+ const gradedResponse = await prisma.studentQuestionProgress.update({
410
+ where: { id: responseId },
411
+ data: {
412
+ isCorrect,
413
+ ...(feedback !== undefined && { feedback }),
414
+ },
415
+ });
416
+
417
+ return gradedResponse;
418
+ }),
419
+ });