@studious-lms/server 1.2.34 → 1.2.36

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,314 @@
1
+ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
2
+ import { z } from "zod";
3
+ import { prisma } from "../lib/prisma.js";
4
+ import { TRPCError } from "@trpc/server";
5
+
6
+ export const commentSelect = {
7
+ replies: {
8
+ select: {
9
+ id: true,
10
+ content: true,
11
+ author: {
12
+ select: {
13
+ id: true,
14
+ username: true,
15
+ profile: {
16
+ select: {
17
+ displayName: true,
18
+ profilePicture: true,
19
+ profilePictureThumbnail: true,
20
+ },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ },
26
+ reactions: {
27
+ select: {
28
+ type: true,
29
+ user: {
30
+ select: {
31
+ id: true,
32
+ username: true,
33
+ profile: {
34
+ select: {
35
+ displayName: true,
36
+ profilePicture: true,
37
+ profilePictureThumbnail: true,
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ export const commentRouter = createTRPCRouter({
47
+ get: protectedProcedure
48
+ .input(z.object({
49
+ id: z.string(),
50
+ }))
51
+ .query(async ({ ctx, input }) => {
52
+ const comment = await prisma.comment.findUnique({
53
+ where: { id: input.id },
54
+ select: {
55
+ replies: {
56
+ select: {
57
+ id: true,
58
+ content: true,
59
+ author: {
60
+ select: {
61
+ id: true,
62
+ username: true,
63
+ profile: {
64
+ select: {
65
+ displayName: true,
66
+ profilePicture: true,
67
+ profilePictureThumbnail: true,
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ reactions: {
75
+ select: {
76
+ type: true,
77
+ user: {
78
+ select: {
79
+ id: true,
80
+ username: true,
81
+ profile: {
82
+ select: {
83
+ displayName: true,
84
+ profilePicture: true,
85
+ profilePictureThumbnail: true,
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ },
92
+ }
93
+ });
94
+ if (!comment) {
95
+ throw new TRPCError({
96
+ code: "NOT_FOUND",
97
+ message: "Comment not found",
98
+ });
99
+ }
100
+ return comment;
101
+ }),
102
+ replyToComment: protectedProcedure
103
+ .input(z.object({
104
+ parentCommentId: z.string(),
105
+ content: z.string(),
106
+ }))
107
+ .mutation(async ({ ctx, input }) => {
108
+ const { parentCommentId, content } = input;
109
+
110
+ const newComment = await prisma.comment.create({
111
+ data: {
112
+ parentCommentId,
113
+ content,
114
+ authorId: ctx.user!.id,
115
+ },
116
+ });
117
+
118
+ return newComment;
119
+ }),
120
+ addReaction: protectedProcedure
121
+ .input(z.object({
122
+ id: z.string(),
123
+ type: z.enum(['THUMBSUP', 'CELEBRATE', 'CARE', 'HEART', 'IDEA', 'HAPPY']),
124
+ }))
125
+ .mutation(async ({ ctx, input }) => {
126
+ if (!ctx.user) {
127
+ throw new TRPCError({
128
+ code: "UNAUTHORIZED",
129
+ message: "User must be authenticated",
130
+ });
131
+ }
132
+
133
+ // Exactly one of announcementId or commentId must be provided
134
+ const comment = await prisma.comment.findUnique({
135
+ where: { id: input.id },
136
+ });
137
+
138
+ const userId = ctx.user.id;
139
+
140
+ // Verify the announcement or comment exists and belongs to the class
141
+ if (comment) {
142
+ const announcement = await prisma.announcement.findFirst({
143
+ where: {
144
+ id: input.id,
145
+ },
146
+ });
147
+
148
+ // Upsert reaction: update if exists, create if not
149
+ const reaction = await prisma.reaction.upsert({
150
+ where: {
151
+ userId_commentId: {
152
+ userId,
153
+ commentId: input.id,
154
+ },
155
+ },
156
+ update: {
157
+ type: input.type,
158
+ },
159
+ create: {
160
+ type: input.type,
161
+ userId,
162
+ commentId: input.id,
163
+ },
164
+ include: {
165
+ user: {
166
+ select: {
167
+ id: true,
168
+ username: true,
169
+ profile: {
170
+ select: {
171
+ displayName: true,
172
+ profilePicture: true,
173
+ profilePictureThumbnail: true,
174
+ },
175
+ },
176
+ },
177
+ },
178
+ },
179
+ });
180
+
181
+ return { reaction };
182
+ }
183
+
184
+ throw new TRPCError({
185
+ code: "INTERNAL_SERVER_ERROR",
186
+ message: "Unexpected error",
187
+ });
188
+ }),
189
+
190
+ removeReaction: protectedProcedure
191
+ .input(z.object({
192
+ commentId: z.string().optional(),
193
+ }))
194
+ .mutation(async ({ ctx, input }) => {
195
+ if (!ctx.user) {
196
+ throw new TRPCError({
197
+ code: "UNAUTHORIZED",
198
+ message: "User must be authenticated",
199
+ });
200
+ }
201
+
202
+ // Exactly one of announcementId or commentId must be provided
203
+ if (!input.commentId) {
204
+ throw new TRPCError({
205
+ code: "BAD_REQUEST",
206
+ message: "Either announcementId or commentId must be provided",
207
+ });
208
+ }
209
+
210
+ const userId = ctx.user.id;
211
+
212
+ const reaction = await prisma.reaction.findUnique({
213
+ where: {
214
+ userId_commentId: {
215
+ userId,
216
+ commentId: input.commentId,
217
+ },
218
+ },
219
+ });
220
+
221
+ if (!reaction) {
222
+ throw new TRPCError({
223
+ code: "NOT_FOUND",
224
+ message: "Reaction not found",
225
+ });
226
+ }
227
+
228
+ await prisma.reaction.delete({
229
+ where: { id: reaction.id },
230
+ });
231
+
232
+ return { success: true };
233
+
234
+ }),
235
+
236
+ getReactions: protectedProcedure
237
+ .input(z.object({
238
+ commentId: z.string().optional(),
239
+ }))
240
+ .query(async ({ ctx, input }) => {
241
+ if (!ctx.user) {
242
+ throw new TRPCError({
243
+ code: "UNAUTHORIZED",
244
+ message: "User must be authenticated",
245
+ });
246
+ }
247
+
248
+ // Exactly one of announcementId or commentId must be provided
249
+ if (!input.commentId) {
250
+ throw new TRPCError({
251
+ code: "BAD_REQUEST",
252
+ message: "Either announcementId or commentId must be provided",
253
+ });
254
+ }
255
+
256
+ const userId = ctx.user.id;
257
+
258
+ // Verify comment exists
259
+ const comment = await prisma.comment.findUnique({
260
+ where: { id: input.commentId },
261
+ include: {
262
+ announcement: {
263
+ select: {
264
+ classId: true,
265
+ },
266
+ },
267
+ },
268
+ });
269
+
270
+ if (!comment) {
271
+ throw new TRPCError({
272
+ code: "NOT_FOUND",
273
+ message: "Comment not found",
274
+ });
275
+ }
276
+
277
+ // Get reaction counts by type
278
+ const reactionCounts = await prisma.reaction.groupBy({
279
+ by: ['type'],
280
+ where: { commentId: input.commentId },
281
+ _count: { type: true },
282
+ });
283
+
284
+ // Get current user's reaction
285
+ const userReaction = await prisma.reaction.findUnique({
286
+ where: {
287
+ userId_commentId: {
288
+ userId,
289
+ commentId: input.commentId,
290
+ },
291
+ },
292
+ });
293
+
294
+ // Format counts
295
+ const counts = {
296
+ THUMBSUP: 0,
297
+ CELEBRATE: 0,
298
+ CARE: 0,
299
+ HEART: 0,
300
+ IDEA: 0,
301
+ HAPPY: 0,
302
+ };
303
+
304
+ reactionCounts.forEach((item) => {
305
+ counts[item.type as keyof typeof counts] = item._count.type;
306
+ });
307
+
308
+ return {
309
+ counts,
310
+ userReaction: userReaction?.type || null,
311
+ total: reactionCounts.reduce((sum, item) => sum + item._count.type, 0),
312
+ };
313
+ }),
314
+ });
@@ -3,6 +3,7 @@ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
3
  import { z } from "zod";
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import { WorksheetQuestionType } from "@prisma/client";
6
+ import { commentSelect } from "./comment.js";
6
7
 
7
8
  export const worksheetRouter = createTRPCRouter({
8
9
  // Get a single worksheet with all questions
@@ -18,6 +19,7 @@ export const worksheetRouter = createTRPCRouter({
18
19
  include: {
19
20
  questions: {
20
21
  orderBy: { createdAt: 'asc' },
22
+ // select: { id: true, type: true, question: true, answer: true, points: true },
21
23
  },
22
24
  class: true,
23
25
  },
@@ -281,7 +283,13 @@ export const worksheetRouter = createTRPCRouter({
281
283
  worksheetId
282
284
  },
283
285
  include: {
284
- responses: true,
286
+ responses: {
287
+ include: {
288
+ comments: {
289
+ select: commentSelect,
290
+ },
291
+ },
292
+ },
285
293
  },
286
294
  });
287
295
 
@@ -297,7 +305,13 @@ export const worksheetRouter = createTRPCRouter({
297
305
  studentId: submission.studentId,
298
306
  },
299
307
  include: {
300
- responses: true,
308
+ responses: {
309
+ include: {
310
+ comments: {
311
+ select: commentSelect,
312
+ },
313
+ },
314
+ },
301
315
  },
302
316
  });
303
317
 
@@ -407,104 +421,106 @@ export const worksheetRouter = createTRPCRouter({
407
421
  return submittedWorksheet;
408
422
  }),
409
423
 
410
- // Get or create a student's worksheet response
411
- getOrCreateWorksheetResponse: protectedProcedure
424
+ // Grade a student's answer
425
+ gradeAnswer: protectedProcedure
412
426
  .input(z.object({
413
- worksheetId: z.string(),
414
- studentId: z.string(),
427
+ questionId: z.string(),
428
+ responseId: z.string().optional(), // StudentQuestionProgress ID (optional for upsert)
429
+ studentWorksheetResponseId: z.string(), // Required for linking to worksheet response
430
+ response: z.string().optional(), // The actual response text (needed if creating new)
431
+ isCorrect: z.boolean(),
432
+ feedback: z.string().optional(),
433
+ markschemeState: z.any().optional(),
434
+ points: z.number().optional(),
415
435
  }))
416
436
  .mutation(async ({ ctx, input }) => {
417
- const { worksheetId, studentId } = input;
437
+ const { responseId, questionId, studentWorksheetResponseId, response, isCorrect, feedback, markschemeState, points } = input;
418
438
 
419
- // Try to find existing response
420
- let worksheetResponse = await prisma.studentWorksheetResponse.findFirst({
421
- where: {
422
- worksheetId,
423
- studentId,
424
- submitted: false, // Only get unsubmitted responses
425
- },
426
- include: {
427
- responses: {
428
- include: {
429
- question: true,
430
- },
431
- },
432
- },
433
- });
434
-
435
- // Create new response if none exists
436
- if (!worksheetResponse) {
437
- worksheetResponse = await prisma.studentWorksheetResponse.create({
439
+ let gradedResponse;
440
+
441
+ if (responseId) {
442
+ // Update existing progress by ID
443
+ gradedResponse = await prisma.studentQuestionProgress.update({
444
+ where: { id: responseId },
438
445
  data: {
439
- worksheetId,
440
- studentId,
441
- },
442
- include: {
443
- responses: {
444
- include: {
445
- question: true,
446
- },
447
- },
446
+ isCorrect,
447
+ ...(feedback !== undefined && { feedback }),
448
+ ...(markschemeState !== undefined && { markschemeState }),
449
+ ...(points !== undefined && { points }),
448
450
  },
449
451
  });
450
- }
452
+ } else {
453
+ // Get the studentId from the worksheet response
454
+ const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
455
+ where: { id: studentWorksheetResponseId },
456
+ select: { studentId: true },
457
+ });
451
458
 
452
- return worksheetResponse;
453
- }),
459
+ if (!worksheetResponse) {
460
+ throw new TRPCError({
461
+ code: 'NOT_FOUND',
462
+ message: 'Student worksheet response not found',
463
+ });
464
+ }
454
465
 
455
- // Get all student responses for a worksheet (teacher view)
456
- getWorksheetResponse: protectedProcedure
457
- .input(z.object({
458
- worksheetId: z.string(),
459
- }))
460
- .query(async ({ ctx, input }) => {
461
- const { worksheetId } = input;
466
+ const { studentId } = worksheetResponse;
462
467
 
463
- const responses = await prisma.studentWorksheetResponse.findFirst({
464
- where: { worksheetId },
465
- include: {
466
- student: {
467
- select: {
468
- id: true,
469
- username: true,
470
- profile: {
471
- select: {
472
- displayName: true,
473
- profilePicture: true,
474
- },
475
- },
476
- },
468
+ // Upsert - find or create the progress record
469
+ const existing = await prisma.studentQuestionProgress.findFirst({
470
+ where: {
471
+ studentId,
472
+ questionId,
473
+ studentWorksheetResponseId,
477
474
  },
478
- responses: {
479
- include: {
480
- question: true,
475
+ });
476
+
477
+ if (existing) {
478
+ // Update existing
479
+ gradedResponse = await prisma.studentQuestionProgress.update({
480
+ where: { id: existing.id },
481
+ data: {
482
+ isCorrect,
483
+ ...(response !== undefined && { response }),
484
+ ...(feedback !== undefined && { feedback }),
485
+ ...(markschemeState !== undefined && { markschemeState }),
486
+ ...(points !== undefined && { points }),
481
487
  },
482
- },
483
- },
484
- orderBy: { submittedAt: 'desc' },
485
- });
488
+ });
489
+ } else {
490
+ // Create new
491
+ gradedResponse = await prisma.studentQuestionProgress.create({
492
+ data: {
493
+ studentId,
494
+ questionId,
495
+ studentWorksheetResponseId,
496
+ response: response || '',
497
+ isCorrect,
498
+ ...(feedback !== undefined && { feedback }),
499
+ ...(markschemeState !== undefined && { markschemeState }),
500
+ ...(points !== undefined && { points: points || 0 }),
501
+ },
502
+ });
503
+ }
504
+ }
486
505
 
487
- return responses;
506
+ return gradedResponse;
488
507
  }),
489
-
490
- // Grade a student's answer
491
- gradeAnswer: protectedProcedure
508
+ addComment: protectedProcedure
492
509
  .input(z.object({
493
- responseId: z.string(), // StudentQuestionProgress ID
494
- isCorrect: z.boolean(),
495
- feedback: z.string().optional(),
510
+ responseId: z.string(),
511
+ comment: z.string(),
496
512
  }))
497
513
  .mutation(async ({ ctx, input }) => {
498
- const { responseId, isCorrect, feedback } = input;
514
+ const { responseId, comment } = input;
499
515
 
500
- const gradedResponse = await prisma.studentQuestionProgress.update({
501
- where: { id: responseId },
516
+ const newComment = await prisma.comment.create({
502
517
  data: {
503
- isCorrect,
504
- ...(feedback !== undefined && { feedback }),
518
+ studentQuestionProgressId: responseId,
519
+ content: comment,
520
+ authorId: ctx.user!.id,
505
521
  },
506
522
  });
507
523
 
508
- return gradedResponse;
524
+ return newComment;
509
525
  }),
510
526
  });