@studious-lms/server 1.0.1 → 1.0.3

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.
Files changed (66) hide show
  1. package/dist/index.js +4 -4
  2. package/dist/middleware/auth.js +1 -1
  3. package/dist/middleware/logging.js +1 -1
  4. package/dist/routers/_app.js +1 -1
  5. package/dist/routers/agenda.js +1 -1
  6. package/dist/routers/announcement.js +1 -1
  7. package/dist/routers/assignment.js +3 -3
  8. package/dist/routers/attendance.js +1 -1
  9. package/dist/routers/auth.js +2 -2
  10. package/dist/routers/class.js +2 -2
  11. package/dist/routers/event.js +1 -1
  12. package/dist/routers/file.js +2 -2
  13. package/dist/routers/section.js +1 -1
  14. package/dist/routers/user.js +2 -2
  15. package/dist/trpc.js +2 -2
  16. package/package.json +1 -6
  17. package/prisma/schema.prisma +228 -0
  18. package/src/exportType.ts +9 -0
  19. package/src/index.ts +94 -0
  20. package/src/lib/fileUpload.ts +163 -0
  21. package/src/lib/googleCloudStorage.ts +94 -0
  22. package/src/lib/prisma.ts +16 -0
  23. package/src/lib/thumbnailGenerator.ts +185 -0
  24. package/src/logger.ts +163 -0
  25. package/src/middleware/auth.ts +191 -0
  26. package/src/middleware/logging.ts +54 -0
  27. package/src/routers/_app.ts +34 -0
  28. package/src/routers/agenda.ts +79 -0
  29. package/src/routers/announcement.ts +134 -0
  30. package/src/routers/assignment.ts +1614 -0
  31. package/src/routers/attendance.ts +284 -0
  32. package/src/routers/auth.ts +286 -0
  33. package/src/routers/class.ts +753 -0
  34. package/src/routers/event.ts +509 -0
  35. package/src/routers/file.ts +96 -0
  36. package/src/routers/section.ts +138 -0
  37. package/src/routers/user.ts +82 -0
  38. package/src/socket/handlers.ts +143 -0
  39. package/src/trpc.ts +90 -0
  40. package/src/types/trpc.ts +15 -0
  41. package/src/utils/email.ts +11 -0
  42. package/src/utils/generateInviteCode.ts +8 -0
  43. package/src/utils/logger.ts +156 -0
  44. package/tsconfig.json +17 -0
  45. package/generated/prisma/client.d.ts +0 -1
  46. package/generated/prisma/client.js +0 -4
  47. package/generated/prisma/default.d.ts +0 -1
  48. package/generated/prisma/default.js +0 -4
  49. package/generated/prisma/edge.d.ts +0 -1
  50. package/generated/prisma/edge.js +0 -389
  51. package/generated/prisma/index-browser.js +0 -375
  52. package/generated/prisma/index.d.ts +0 -34865
  53. package/generated/prisma/index.js +0 -410
  54. package/generated/prisma/libquery_engine-darwin-arm64.dylib.node +0 -0
  55. package/generated/prisma/package.json +0 -140
  56. package/generated/prisma/runtime/edge-esm.js +0 -34
  57. package/generated/prisma/runtime/edge.js +0 -34
  58. package/generated/prisma/runtime/index-browser.d.ts +0 -370
  59. package/generated/prisma/runtime/index-browser.js +0 -16
  60. package/generated/prisma/runtime/library.d.ts +0 -3647
  61. package/generated/prisma/runtime/library.js +0 -146
  62. package/generated/prisma/runtime/react-native.js +0 -83
  63. package/generated/prisma/runtime/wasm.js +0 -35
  64. package/generated/prisma/schema.prisma +0 -304
  65. package/generated/prisma/wasm.d.ts +0 -1
  66. package/generated/prisma/wasm.js +0 -375
@@ -0,0 +1,1614 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedProcedure, protectedClassMemberProcedure, protectedTeacherProcedure } from "../trpc";
3
+ import { TRPCError } from "@trpc/server";
4
+ import { prisma } from "../lib/prisma";
5
+ import { uploadFiles, type UploadedFile } from "../lib/fileUpload";
6
+ import { deleteFile } from "../lib/googleCloudStorage";
7
+
8
+ const fileSchema = z.object({
9
+ name: z.string(),
10
+ type: z.string(),
11
+ size: z.number(),
12
+ data: z.string(), // base64 encoded file data
13
+ });
14
+
15
+ const createAssignmentSchema = z.object({
16
+ classId: z.string(),
17
+ title: z.string(),
18
+ instructions: z.string(),
19
+ dueDate: z.string(),
20
+ files: z.array(fileSchema).optional(),
21
+ maxGrade: z.number().optional(),
22
+ graded: z.boolean().optional(),
23
+ weight: z.number().optional(),
24
+ sectionId: z.string().optional(),
25
+ type: z.enum(['HOMEWORK', 'QUIZ', 'TEST', 'PROJECT', 'ESSAY', 'DISCUSSION', 'PRESENTATION', 'LAB', 'OTHER']).optional(),
26
+ markSchemeId: z.string().optional(),
27
+ gradingBoundaryId: z.string().optional(),
28
+ });
29
+
30
+ const updateAssignmentSchema = z.object({
31
+ classId: z.string(),
32
+ id: z.string(),
33
+ title: z.string().optional(),
34
+ instructions: z.string().optional(),
35
+ dueDate: z.string().optional(),
36
+ files: z.array(fileSchema).optional(),
37
+ removedAttachments: z.array(z.string()).optional(),
38
+ maxGrade: z.number().optional(),
39
+ graded: z.boolean().optional(),
40
+ weight: z.number().optional(),
41
+ sectionId: z.string().nullable().optional(),
42
+ type: z.enum(['HOMEWORK', 'QUIZ', 'TEST', 'PROJECT', 'ESSAY', 'DISCUSSION', 'PRESENTATION', 'LAB', 'OTHER']).optional(),
43
+ });
44
+
45
+ const deleteAssignmentSchema = z.object({
46
+ id: z.string(),
47
+ classId: z.string(),
48
+ });
49
+
50
+ const getAssignmentSchema = z.object({
51
+ id: z.string(),
52
+ classId: z.string(),
53
+ });
54
+
55
+ const submissionSchema = z.object({
56
+ assignmentId: z.string(),
57
+ classId: z.string(),
58
+ submissionId: z.string(),
59
+ submit: z.boolean().optional(),
60
+ newAttachments: z.array(fileSchema).optional(),
61
+ removedAttachments: z.array(z.string()).optional(),
62
+ });
63
+
64
+ const updateSubmissionSchema = z.object({
65
+ assignmentId: z.string(),
66
+ classId: z.string(),
67
+ submissionId: z.string(),
68
+ return: z.boolean().optional(),
69
+ gradeReceived: z.number().nullable().optional(),
70
+ newAttachments: z.array(fileSchema).optional(),
71
+ removedAttachments: z.array(z.string()).optional(),
72
+ rubricGrades: z.array(z.object({
73
+ criteriaId: z.string(),
74
+ selectedLevelId: z.string(),
75
+ points: z.number(),
76
+ comments: z.string(),
77
+ })).optional(),
78
+ });
79
+
80
+ export const assignmentRouter = createTRPCRouter({
81
+ create: protectedProcedure
82
+ .input(createAssignmentSchema)
83
+ .mutation(async ({ ctx, input }) => {
84
+ const { classId, title, instructions, dueDate, files, maxGrade, graded, weight, sectionId, type, markSchemeId, gradingBoundaryId } = input;
85
+
86
+ if (!ctx.user) {
87
+ throw new TRPCError({
88
+ code: "UNAUTHORIZED",
89
+ message: "User must be authenticated",
90
+ });
91
+ }
92
+
93
+ // Get all students in the class
94
+ const classData = await prisma.class.findUnique({
95
+ where: { id: classId },
96
+ include: {
97
+ students: {
98
+ select: { id: true }
99
+ }
100
+ }
101
+ });
102
+
103
+ if (!classData) {
104
+ throw new TRPCError({
105
+ code: "NOT_FOUND",
106
+ message: "Class not found",
107
+ });
108
+ }
109
+
110
+ const rubric = await prisma.markScheme.findUnique({
111
+ where: { id: markSchemeId },
112
+ select: {
113
+ structured: true,
114
+ }
115
+ });
116
+
117
+ const parsedRubric = JSON.parse(rubric?.structured || "{}");
118
+
119
+ // Calculate max grade from rubric criteria levels
120
+ const computedMaxGrade = parsedRubric.criteria.reduce((acc: number, criterion: any) => {
121
+ const maxPoints = Math.max(...criterion.levels.map((level: any) => level.points));
122
+ return acc + maxPoints;
123
+ }, 0);
124
+
125
+ // Create assignment with submissions for all students
126
+ const assignment = await prisma.assignment.create({
127
+ data: {
128
+ title,
129
+ instructions,
130
+ dueDate: new Date(dueDate),
131
+ maxGrade: markSchemeId ? computedMaxGrade : maxGrade,
132
+ graded,
133
+ weight,
134
+ type,
135
+ class: {
136
+ connect: { id: classId }
137
+ },
138
+ ...(sectionId && {
139
+ section: {
140
+ connect: { id: sectionId }
141
+ }
142
+ }),
143
+ ...(markSchemeId && {
144
+ markScheme: {
145
+ connect: { id: markSchemeId }
146
+ }
147
+ }),
148
+ ...(gradingBoundaryId && {
149
+ gradingBoundary: {
150
+ connect: { id: gradingBoundaryId }
151
+ }
152
+ }),
153
+ submissions: {
154
+ create: classData.students.map((student) => ({
155
+ student: {
156
+ connect: { id: student.id }
157
+ }
158
+ }))
159
+ },
160
+ teacher: {
161
+ connect: { id: ctx.user.id }
162
+ }
163
+ },
164
+ select: {
165
+ id: true,
166
+ title: true,
167
+ instructions: true,
168
+ dueDate: true,
169
+ maxGrade: true,
170
+ graded: true,
171
+ weight: true,
172
+ type: true,
173
+ attachments: {
174
+ select: {
175
+ id: true,
176
+ name: true,
177
+ type: true,
178
+ }
179
+ },
180
+ section: {
181
+ select: {
182
+ id: true,
183
+ name: true
184
+ }
185
+ },
186
+ teacher: {
187
+ select: {
188
+ id: true,
189
+ username: true
190
+ }
191
+ },
192
+ class: {
193
+ select: {
194
+ id: true,
195
+ name: true
196
+ }
197
+ }
198
+ }
199
+ });
200
+ // Upload files if provided
201
+ let uploadedFiles: UploadedFile[] = [];
202
+ if (files && files.length > 0) {
203
+ // Store files in a class and assignment specific directory
204
+ uploadedFiles = await uploadFiles(files, ctx.user.id, `class/${classId}/assignment/${assignment.id}`);
205
+ }
206
+
207
+ // Update assignment with new file attachments
208
+ if (uploadedFiles.length > 0) {
209
+ await prisma.assignment.update({
210
+ where: { id: assignment.id },
211
+ data: {
212
+ attachments: {
213
+ create: uploadedFiles.map(file => ({
214
+ name: file.name,
215
+ type: file.type,
216
+ size: file.size,
217
+ path: file.path,
218
+ ...(file.thumbnailId && {
219
+ thumbnail: {
220
+ connect: { id: file.thumbnailId }
221
+ }
222
+ })
223
+ }))
224
+ }
225
+ }
226
+ });
227
+ }
228
+
229
+ return assignment;
230
+ }),
231
+ update: protectedProcedure
232
+ .input(updateAssignmentSchema)
233
+ .mutation(async ({ ctx, input }) => {
234
+ const { id, title, instructions, dueDate, files, maxGrade, graded, weight, sectionId, type } = input;
235
+
236
+ if (!ctx.user) {
237
+ throw new TRPCError({
238
+ code: "UNAUTHORIZED",
239
+ message: "User must be authenticated",
240
+ });
241
+ }
242
+
243
+ // Get the assignment with current attachments
244
+ const assignment = await prisma.assignment.findFirst({
245
+ where: {
246
+ id,
247
+ teacherId: ctx.user.id,
248
+ },
249
+ include: {
250
+ attachments: {
251
+ select: {
252
+ id: true,
253
+ name: true,
254
+ type: true,
255
+ thumbnail: {
256
+ select: {
257
+ path: true
258
+ }
259
+ }
260
+ },
261
+ },
262
+ class: {
263
+ select: {
264
+ id: true,
265
+ name: true
266
+ }
267
+ },
268
+ },
269
+ });
270
+
271
+ if (!assignment) {
272
+ throw new TRPCError({
273
+ code: "NOT_FOUND",
274
+ message: "Assignment not found",
275
+ });
276
+ }
277
+
278
+ // Upload new files if provided
279
+ let uploadedFiles: UploadedFile[] = [];
280
+ if (files && files.length > 0) {
281
+ // Store files in a class and assignment specific directory
282
+ uploadedFiles = await uploadFiles(files, ctx.user.id, `class/${assignment.classId}/assignment/${id}`);
283
+ }
284
+
285
+ // Update assignment
286
+ const updatedAssignment = await prisma.assignment.update({
287
+ where: { id },
288
+ data: {
289
+ ...(title && { title }),
290
+ ...(instructions && { instructions }),
291
+ ...(dueDate && { dueDate: new Date(dueDate) }),
292
+ ...(maxGrade && { maxGrade }),
293
+ ...(graded !== undefined && { graded }),
294
+ ...(weight && { weight }),
295
+ ...(type && { type }),
296
+ ...(sectionId !== undefined && {
297
+ section: sectionId ? {
298
+ connect: { id: sectionId }
299
+ } : {
300
+ disconnect: true
301
+ }
302
+ }),
303
+ ...(uploadedFiles.length > 0 && {
304
+ attachments: {
305
+ create: uploadedFiles.map(file => ({
306
+ name: file.name,
307
+ type: file.type,
308
+ size: file.size,
309
+ path: file.path,
310
+ ...(file.thumbnailId && {
311
+ thumbnail: {
312
+ connect: { id: file.thumbnailId }
313
+ }
314
+ })
315
+ }))
316
+ }
317
+ }),
318
+ ...(input.removedAttachments && input.removedAttachments.length > 0 && {
319
+ attachments: {
320
+ deleteMany: {
321
+ id: { in: input.removedAttachments }
322
+ }
323
+ }
324
+ }),
325
+ },
326
+ select: {
327
+ id: true,
328
+ title: true,
329
+ instructions: true,
330
+ dueDate: true,
331
+ maxGrade: true,
332
+ graded: true,
333
+ weight: true,
334
+ type: true,
335
+ createdAt: true,
336
+ submissions: {
337
+ select: {
338
+ student: {
339
+ select: {
340
+ id: true,
341
+ username: true
342
+ }
343
+ }
344
+ }
345
+ },
346
+ attachments: {
347
+ select: {
348
+ id: true,
349
+ name: true,
350
+ type: true,
351
+ thumbnail: true
352
+ }
353
+ },
354
+ section: true,
355
+ teacher: true,
356
+ class: true
357
+ }
358
+ });
359
+
360
+
361
+ if (assignment.markSchemeId) {
362
+ const rubric = await prisma.markScheme.findUnique({
363
+ where: { id: assignment.markSchemeId },
364
+ select: {
365
+ structured: true,
366
+ }
367
+ });
368
+ const parsedRubric = JSON.parse(rubric?.structured || "{}");
369
+ const computedMaxGrade = parsedRubric.criteria.reduce((acc: number, criterion: any) => {
370
+ const maxPoints = Math.max(...criterion.levels.map((level: any) => level.points));
371
+ return acc + maxPoints;
372
+ }, 0);
373
+
374
+ await prisma.assignment.update({
375
+ where: { id },
376
+ data: {
377
+ maxGrade: computedMaxGrade,
378
+ }
379
+ });
380
+ }
381
+
382
+
383
+ return updatedAssignment;
384
+ }),
385
+
386
+ delete: protectedProcedure
387
+ .input(deleteAssignmentSchema)
388
+ .mutation(async ({ ctx, input }) => {
389
+ const { id, classId } = input;
390
+
391
+ if (!ctx.user) {
392
+ throw new TRPCError({
393
+ code: "UNAUTHORIZED",
394
+ message: "User must be authenticated",
395
+ });
396
+ }
397
+
398
+ // Get the assignment with all related files
399
+ const assignment = await prisma.assignment.findFirst({
400
+ where: {
401
+ id,
402
+ teacherId: ctx.user.id,
403
+ },
404
+ include: {
405
+ attachments: {
406
+ include: {
407
+ thumbnail: true
408
+ }
409
+ },
410
+ submissions: {
411
+ include: {
412
+ attachments: {
413
+ include: {
414
+ thumbnail: true
415
+ }
416
+ },
417
+ annotations: {
418
+ include: {
419
+ thumbnail: true
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ });
426
+
427
+ if (!assignment) {
428
+ throw new TRPCError({
429
+ code: "NOT_FOUND",
430
+ message: "Assignment not found",
431
+ });
432
+ }
433
+
434
+ // Delete all files from storage
435
+ const filesToDelete = [
436
+ ...assignment.attachments,
437
+ ...assignment.submissions.flatMap(sub => [...sub.attachments, ...sub.annotations])
438
+ ];
439
+
440
+ // Delete files from storage
441
+ await Promise.all(filesToDelete.map(async (file) => {
442
+ try {
443
+ // Delete the main file
444
+ await deleteFile(file.path);
445
+
446
+ // Delete thumbnail if it exists
447
+ if (file.thumbnail) {
448
+ await deleteFile(file.thumbnail.path);
449
+ }
450
+ } catch (error) {
451
+ console.warn(`Failed to delete file ${file.path}:`, error);
452
+ }
453
+ }));
454
+
455
+ // Delete the assignment (this will cascade delete all related records)
456
+ await prisma.assignment.delete({
457
+ where: { id },
458
+ });
459
+
460
+ return {
461
+ id,
462
+ };
463
+ }),
464
+
465
+ get: protectedProcedure
466
+ .input(getAssignmentSchema)
467
+ .query(async ({ ctx, input }) => {
468
+ const { id, classId } = input;
469
+
470
+ if (!ctx.user) {
471
+ throw new TRPCError({
472
+ code: "UNAUTHORIZED",
473
+ message: "User must be authenticated",
474
+ });
475
+ }
476
+
477
+ const assignment = await prisma.assignment.findUnique({
478
+ where: {
479
+ id,
480
+ // classId,
481
+ },
482
+ include: {
483
+ submissions: {
484
+ select: {
485
+ student: {
486
+ select: {
487
+ id: true,
488
+ username: true
489
+ }
490
+ }
491
+ }
492
+ },
493
+ attachments: {
494
+ select: {
495
+ id: true,
496
+ name: true,
497
+ type: true,
498
+ size: true,
499
+ path: true,
500
+ thumbnailId: true,
501
+ }
502
+ },
503
+ section: {
504
+ select: {
505
+ id: true,
506
+ name: true,
507
+ }
508
+ },
509
+ teacher: {
510
+ select: {
511
+ id: true,
512
+ username: true
513
+ }
514
+ },
515
+ class: {
516
+ select: {
517
+ id: true,
518
+ name: true
519
+ }
520
+ },
521
+ eventAttached: {
522
+ select: {
523
+ id: true,
524
+ name: true,
525
+ startTime: true,
526
+ endTime: true,
527
+ location: true,
528
+ remarks: true,
529
+ }
530
+ },
531
+ markScheme: {
532
+ select: {
533
+ id: true,
534
+ structured: true,
535
+ }
536
+ },
537
+ gradingBoundary: {
538
+ select: {
539
+ id: true,
540
+ structured: true,
541
+ }
542
+ }
543
+ }
544
+ });
545
+
546
+ if (!assignment) {
547
+ throw new TRPCError({
548
+ code: "NOT_FOUND",
549
+ message: "Assignment not found",
550
+ });
551
+ }
552
+
553
+ const sections = await prisma.section.findMany({
554
+ where: {
555
+ classId: assignment.classId,
556
+ },
557
+ select: {
558
+ id: true,
559
+ name: true,
560
+ },
561
+ });
562
+
563
+ return { ...assignment, sections };
564
+ }),
565
+
566
+ getSubmission: protectedClassMemberProcedure
567
+ .input(z.object({
568
+ assignmentId: z.string(),
569
+ classId: z.string(),
570
+ }))
571
+ .query(async ({ ctx, input }) => {
572
+ if (!ctx.user) {
573
+ throw new TRPCError({
574
+ code: "UNAUTHORIZED",
575
+ message: "User must be authenticated",
576
+ });
577
+ }
578
+
579
+ const { assignmentId } = input;
580
+
581
+ const submission = await prisma.submission.findFirst({
582
+ where: {
583
+ assignmentId,
584
+ studentId: ctx.user.id,
585
+ },
586
+ include: {
587
+ attachments: true,
588
+ student: {
589
+ select: {
590
+ id: true,
591
+ username: true,
592
+ },
593
+ },
594
+ assignment: {
595
+ include: {
596
+ class: true,
597
+ markScheme: {
598
+ select: {
599
+ id: true,
600
+ structured: true,
601
+ }
602
+ },
603
+ gradingBoundary: {
604
+ select: {
605
+ id: true,
606
+ structured: true,
607
+ }
608
+ }
609
+ },
610
+ },
611
+ annotations: true,
612
+ },
613
+ });
614
+
615
+ if (!submission) {
616
+ // Create a new submission if it doesn't exist
617
+ return await prisma.submission.create({
618
+ data: {
619
+ assignment: {
620
+ connect: { id: assignmentId },
621
+ },
622
+ student: {
623
+ connect: { id: ctx.user.id },
624
+ },
625
+ },
626
+ include: {
627
+ attachments: true,
628
+ annotations: true,
629
+ student: {
630
+ select: {
631
+ id: true,
632
+ username: true,
633
+ },
634
+ },
635
+ assignment: {
636
+ include: {
637
+ class: true,
638
+ markScheme: {
639
+ select: {
640
+ id: true,
641
+ structured: true,
642
+ }
643
+ },
644
+ gradingBoundary: {
645
+ select: {
646
+ id: true,
647
+ structured: true,
648
+ }
649
+ }
650
+ },
651
+ },
652
+ },
653
+ });
654
+ }
655
+
656
+ return {
657
+ ...submission,
658
+ late: submission.assignment.dueDate < new Date(),
659
+ };
660
+ }),
661
+
662
+ getSubmissionById: protectedTeacherProcedure
663
+ .input(z.object({
664
+ submissionId: z.string(),
665
+ classId: z.string(),
666
+ }))
667
+ .query(async ({ ctx, input }) => {
668
+ if (!ctx.user) {
669
+ throw new TRPCError({
670
+ code: "UNAUTHORIZED",
671
+ message: "User must be authenticated",
672
+ });
673
+ }
674
+
675
+ const { submissionId, classId } = input;
676
+
677
+ const submission = await prisma.submission.findFirst({
678
+ where: {
679
+ id: submissionId,
680
+ assignment: {
681
+ classId,
682
+ class: {
683
+ teachers: {
684
+ some: {
685
+ id: ctx.user.id
686
+ }
687
+ }
688
+ }
689
+ },
690
+ },
691
+ include: {
692
+ attachments: true,
693
+ annotations: true,
694
+ student: {
695
+ select: {
696
+ id: true,
697
+ username: true,
698
+ },
699
+ },
700
+ assignment: {
701
+ include: {
702
+ class: true,
703
+ markScheme: {
704
+ select: {
705
+ id: true,
706
+ structured: true,
707
+ }
708
+ },
709
+ gradingBoundary: {
710
+ select: {
711
+ id: true,
712
+ structured: true,
713
+ }
714
+ }
715
+ },
716
+ },
717
+ },
718
+ });
719
+
720
+ if (!submission) {
721
+ throw new TRPCError({
722
+ code: "NOT_FOUND",
723
+ message: "Submission not found",
724
+ });
725
+ }
726
+
727
+ return {
728
+ ...submission,
729
+ late: submission.assignment.dueDate < new Date(),
730
+ };
731
+ }),
732
+
733
+ updateSubmission: protectedClassMemberProcedure
734
+ .input(submissionSchema)
735
+ .mutation(async ({ ctx, input }) => {
736
+ if (!ctx.user) {
737
+ throw new TRPCError({
738
+ code: "UNAUTHORIZED",
739
+ message: "User must be authenticated",
740
+ });
741
+ }
742
+
743
+ const { submissionId, submit, newAttachments, removedAttachments } = input;
744
+
745
+ const submission = await prisma.submission.findFirst({
746
+ where: {
747
+ id: submissionId,
748
+ OR: [
749
+ {
750
+ student: {
751
+ id: ctx.user.id,
752
+ },
753
+ },
754
+ {
755
+ assignment: {
756
+ class: {
757
+ teachers: {
758
+ some: {
759
+ id: ctx.user.id,
760
+ },
761
+ },
762
+ },
763
+ },
764
+ },
765
+ ],
766
+ },
767
+ include: {
768
+ attachments: {
769
+ include: {
770
+ thumbnail: true
771
+ }
772
+ },
773
+ assignment: {
774
+ include: {
775
+ class: true,
776
+ markScheme: {
777
+ select: {
778
+ id: true,
779
+ structured: true,
780
+ }
781
+ },
782
+ gradingBoundary: {
783
+ select: {
784
+ id: true,
785
+ structured: true,
786
+ }
787
+ }
788
+ },
789
+ },
790
+ },
791
+ });
792
+
793
+ if (!submission) {
794
+ throw new TRPCError({
795
+ code: "NOT_FOUND",
796
+ message: "Submission not found",
797
+ });
798
+ }
799
+
800
+ if (submit !== undefined) {
801
+ // Toggle submission status
802
+ return await prisma.submission.update({
803
+ where: { id: submission.id },
804
+ data: {
805
+ submitted: !submission.submitted,
806
+ submittedAt: new Date(),
807
+ },
808
+ include: {
809
+ attachments: true,
810
+ student: {
811
+ select: {
812
+ id: true,
813
+ username: true,
814
+ },
815
+ },
816
+ assignment: {
817
+ include: {
818
+ class: true,
819
+ markScheme: {
820
+ select: {
821
+ id: true,
822
+ structured: true,
823
+ }
824
+ },
825
+ gradingBoundary: {
826
+ select: {
827
+ id: true,
828
+ structured: true,
829
+ }
830
+ }
831
+ },
832
+ },
833
+ },
834
+ });
835
+ }
836
+
837
+ let uploadedFiles: UploadedFile[] = [];
838
+ if (newAttachments && newAttachments.length > 0) {
839
+ // Store files in a class and assignment specific directory
840
+ uploadedFiles = await uploadFiles(newAttachments, ctx.user.id, `class/${submission.assignment.classId}/assignment/${submission.assignmentId}/submission/${submission.id}`);
841
+ }
842
+
843
+ // Update submission with new file attachments
844
+ if (uploadedFiles.length > 0) {
845
+ await prisma.submission.update({
846
+ where: { id: submission.id },
847
+ data: {
848
+ attachments: {
849
+ create: uploadedFiles.map(file => ({
850
+ name: file.name,
851
+ type: file.type,
852
+ size: file.size,
853
+ path: file.path,
854
+ ...(file.thumbnailId && {
855
+ thumbnail: {
856
+ connect: { id: file.thumbnailId }
857
+ }
858
+ })
859
+ }))
860
+ }
861
+ }
862
+ });
863
+ }
864
+
865
+ // Delete removed attachments if any
866
+ if (removedAttachments && removedAttachments.length > 0) {
867
+ const filesToDelete = submission.attachments.filter((file) =>
868
+ removedAttachments.includes(file.id)
869
+ );
870
+
871
+ // Delete files from storage
872
+ await Promise.all(filesToDelete.map(async (file) => {
873
+ try {
874
+ // Delete the main file
875
+ await deleteFile(file.path);
876
+
877
+ // Delete thumbnail if it exists
878
+ if (file.thumbnail?.path) {
879
+ await deleteFile(file.thumbnail.path);
880
+ }
881
+ } catch (error) {
882
+ console.warn(`Failed to delete file ${file.path}:`, error);
883
+ }
884
+ }));
885
+ }
886
+
887
+ // Update submission with attachments
888
+ return await prisma.submission.update({
889
+ where: { id: submission.id },
890
+ data: {
891
+ ...(removedAttachments && removedAttachments.length > 0 && {
892
+ attachments: {
893
+ deleteMany: {
894
+ id: { in: removedAttachments },
895
+ },
896
+ },
897
+ }),
898
+ },
899
+ include: {
900
+ attachments: {
901
+ include: {
902
+ thumbnail: true
903
+ }
904
+ },
905
+ student: {
906
+ select: {
907
+ id: true,
908
+ username: true,
909
+ },
910
+ },
911
+ assignment: {
912
+ include: {
913
+ class: true,
914
+ markScheme: {
915
+ select: {
916
+ id: true,
917
+ structured: true,
918
+ }
919
+ },
920
+ gradingBoundary: {
921
+ select: {
922
+ id: true,
923
+ structured: true,
924
+ }
925
+ }
926
+ },
927
+ },
928
+ },
929
+ });
930
+ }),
931
+
932
+ getSubmissions: protectedTeacherProcedure
933
+ .input(z.object({
934
+ assignmentId: z.string(),
935
+ classId: z.string(),
936
+ }))
937
+ .query(async ({ ctx, input }) => {
938
+ if (!ctx.user) {
939
+ throw new TRPCError({
940
+ code: "UNAUTHORIZED",
941
+ message: "User must be authenticated",
942
+ });
943
+ }
944
+
945
+ const { assignmentId } = input;
946
+
947
+ const submissions = await prisma.submission.findMany({
948
+ where: {
949
+ assignment: {
950
+ id: assignmentId,
951
+ class: {
952
+ teachers: {
953
+ some: { id: ctx.user.id },
954
+ },
955
+ },
956
+ },
957
+ },
958
+ include: {
959
+ attachments: {
960
+ include: {
961
+ thumbnail: true
962
+ }
963
+ },
964
+ student: {
965
+ select: {
966
+ id: true,
967
+ username: true,
968
+ },
969
+ },
970
+ assignment: {
971
+ include: {
972
+ class: true,
973
+ markScheme: {
974
+ select: {
975
+ id: true,
976
+ structured: true,
977
+ }
978
+ },
979
+ gradingBoundary: {
980
+ select: {
981
+ id: true,
982
+ structured: true,
983
+ }
984
+ }
985
+ },
986
+ },
987
+ },
988
+ });
989
+
990
+ return submissions.map(submission => ({
991
+ ...submission,
992
+ late: submission.assignment.dueDate < new Date(),
993
+ }));
994
+ }),
995
+
996
+ updateSubmissionAsTeacher: protectedTeacherProcedure
997
+ .input(updateSubmissionSchema)
998
+ .mutation(async ({ ctx, input }) => {
999
+ if (!ctx.user) {
1000
+ throw new TRPCError({
1001
+ code: "UNAUTHORIZED",
1002
+ message: "User must be authenticated",
1003
+ });
1004
+ }
1005
+
1006
+ const { submissionId, return: returnSubmission, gradeReceived, newAttachments, removedAttachments, rubricGrades } = input;
1007
+
1008
+ const submission = await prisma.submission.findFirst({
1009
+ where: {
1010
+ id: submissionId,
1011
+ assignment: {
1012
+ class: {
1013
+ teachers: {
1014
+ some: { id: ctx.user.id },
1015
+ },
1016
+ },
1017
+ },
1018
+ },
1019
+ include: {
1020
+ attachments: {
1021
+ include: {
1022
+ thumbnail: true
1023
+ }
1024
+ },
1025
+ annotations: {
1026
+ include: {
1027
+ thumbnail: true
1028
+ }
1029
+ },
1030
+ assignment: {
1031
+ include: {
1032
+ class: true,
1033
+ markScheme: {
1034
+ select: {
1035
+ id: true,
1036
+ structured: true,
1037
+ }
1038
+ },
1039
+ gradingBoundary: {
1040
+ select: {
1041
+ id: true,
1042
+ structured: true,
1043
+ }
1044
+ }
1045
+ },
1046
+ },
1047
+ },
1048
+ });
1049
+
1050
+ if (!submission) {
1051
+ throw new TRPCError({
1052
+ code: "NOT_FOUND",
1053
+ message: "Submission not found",
1054
+ });
1055
+ }
1056
+
1057
+ if (returnSubmission !== undefined) {
1058
+ // Toggle return status
1059
+ return await prisma.submission.update({
1060
+ where: { id: submissionId },
1061
+ data: {
1062
+ returned: !submission.returned,
1063
+ },
1064
+ include: {
1065
+ attachments: true,
1066
+ student: {
1067
+ select: {
1068
+ id: true,
1069
+ username: true,
1070
+ },
1071
+ },
1072
+ assignment: {
1073
+ include: {
1074
+ class: true,
1075
+ markScheme: {
1076
+ select: {
1077
+ id: true,
1078
+ structured: true,
1079
+ }
1080
+ },
1081
+ gradingBoundary: {
1082
+ select: {
1083
+ id: true,
1084
+ structured: true,
1085
+ }
1086
+ }
1087
+ },
1088
+ },
1089
+ },
1090
+ });
1091
+ }
1092
+
1093
+ let uploadedFiles: UploadedFile[] = [];
1094
+ if (newAttachments && newAttachments.length > 0) {
1095
+ // Store files in a class and assignment specific directory
1096
+ uploadedFiles = await uploadFiles(newAttachments, ctx.user.id, `class/${submission.assignment.classId}/assignment/${submission.assignmentId}/submission/${submission.id}/annotations`);
1097
+ }
1098
+
1099
+ // Update submission with new file attachments
1100
+ if (uploadedFiles.length > 0) {
1101
+ await prisma.submission.update({
1102
+ where: { id: submission.id },
1103
+ data: {
1104
+ annotations: {
1105
+ create: uploadedFiles.map(file => ({
1106
+ name: file.name,
1107
+ type: file.type,
1108
+ size: file.size,
1109
+ path: file.path,
1110
+ ...(file.thumbnailId && {
1111
+ thumbnail: {
1112
+ connect: { id: file.thumbnailId }
1113
+ }
1114
+ })
1115
+ }))
1116
+ }
1117
+ }
1118
+ });
1119
+ }
1120
+
1121
+ // Delete removed attachments if any
1122
+ if (removedAttachments && removedAttachments.length > 0) {
1123
+ const filesToDelete = submission.annotations.filter((file) =>
1124
+ removedAttachments.includes(file.id)
1125
+ );
1126
+
1127
+ // Delete files from storage
1128
+ await Promise.all(filesToDelete.map(async (file) => {
1129
+ try {
1130
+ // Delete the main file
1131
+ await deleteFile(file.path);
1132
+
1133
+ // Delete thumbnail if it exists
1134
+ if (file.thumbnail?.path) {
1135
+ await deleteFile(file.thumbnail.path);
1136
+ }
1137
+ } catch (error) {
1138
+ console.warn(`Failed to delete file ${file.path}:`, error);
1139
+ }
1140
+ }));
1141
+ }
1142
+
1143
+ // Update submission with grade and attachments
1144
+ return await prisma.submission.update({
1145
+ where: { id: submissionId },
1146
+ data: {
1147
+ ...(gradeReceived !== undefined && { gradeReceived }),
1148
+ ...(rubricGrades && { rubricState: JSON.stringify(rubricGrades) }),
1149
+ ...(removedAttachments && removedAttachments.length > 0 && {
1150
+ annotations: {
1151
+ deleteMany: {
1152
+ id: { in: removedAttachments },
1153
+ },
1154
+ },
1155
+ }),
1156
+ },
1157
+ include: {
1158
+ attachments: {
1159
+ include: {
1160
+ thumbnail: true
1161
+ }
1162
+ },
1163
+ annotations: {
1164
+ include: {
1165
+ thumbnail: true
1166
+ }
1167
+ },
1168
+ student: {
1169
+ select: {
1170
+ id: true,
1171
+ username: true,
1172
+ },
1173
+ },
1174
+ assignment: {
1175
+ include: {
1176
+ class: true,
1177
+ markScheme: {
1178
+ select: {
1179
+ id: true,
1180
+ structured: true,
1181
+ }
1182
+ },
1183
+ gradingBoundary: {
1184
+ select: {
1185
+ id: true,
1186
+ structured: true,
1187
+ }
1188
+ }
1189
+ },
1190
+ },
1191
+ },
1192
+ });
1193
+ }),
1194
+
1195
+ attachToEvent: protectedTeacherProcedure
1196
+ .input(z.object({
1197
+ assignmentId: z.string(),
1198
+ eventId: z.string(),
1199
+ }))
1200
+ .mutation(async ({ ctx, input }) => {
1201
+ if (!ctx.user) {
1202
+ throw new TRPCError({
1203
+ code: "UNAUTHORIZED",
1204
+ message: "User must be authenticated",
1205
+ });
1206
+ }
1207
+
1208
+ const { assignmentId, eventId } = input;
1209
+
1210
+ // Check if assignment exists and user is a teacher of the class
1211
+ const assignment = await prisma.assignment.findFirst({
1212
+ where: {
1213
+ id: assignmentId,
1214
+ class: {
1215
+ teachers: {
1216
+ some: { id: ctx.user.id },
1217
+ },
1218
+ },
1219
+ },
1220
+ include: {
1221
+ class: true,
1222
+ },
1223
+ });
1224
+
1225
+ if (!assignment) {
1226
+ throw new TRPCError({
1227
+ code: "NOT_FOUND",
1228
+ message: "Assignment not found or you are not authorized",
1229
+ });
1230
+ }
1231
+
1232
+ // Check if event exists and belongs to the same class
1233
+ const event = await prisma.event.findFirst({
1234
+ where: {
1235
+ id: eventId,
1236
+ classId: assignment.classId,
1237
+ },
1238
+ });
1239
+
1240
+ if (!event) {
1241
+ throw new TRPCError({
1242
+ code: "NOT_FOUND",
1243
+ message: "Event not found or does not belong to the same class",
1244
+ });
1245
+ }
1246
+
1247
+ // Attach assignment to event
1248
+ const updatedAssignment = await prisma.assignment.update({
1249
+ where: { id: assignmentId },
1250
+ data: {
1251
+ eventAttached: {
1252
+ connect: { id: eventId }
1253
+ }
1254
+ },
1255
+ include: {
1256
+ attachments: {
1257
+ select: {
1258
+ id: true,
1259
+ name: true,
1260
+ type: true,
1261
+ }
1262
+ },
1263
+ section: {
1264
+ select: {
1265
+ id: true,
1266
+ name: true
1267
+ }
1268
+ },
1269
+ teacher: {
1270
+ select: {
1271
+ id: true,
1272
+ username: true
1273
+ }
1274
+ },
1275
+ eventAttached: {
1276
+ select: {
1277
+ id: true,
1278
+ name: true,
1279
+ startTime: true,
1280
+ endTime: true,
1281
+ }
1282
+ }
1283
+ }
1284
+ });
1285
+
1286
+ return { assignment: updatedAssignment };
1287
+ }),
1288
+
1289
+ detachEvent: protectedTeacherProcedure
1290
+ .input(z.object({
1291
+ assignmentId: z.string(),
1292
+ }))
1293
+ .mutation(async ({ ctx, input }) => {
1294
+ if (!ctx.user) {
1295
+ throw new TRPCError({
1296
+ code: "UNAUTHORIZED",
1297
+ message: "User must be authenticated",
1298
+ });
1299
+ }
1300
+
1301
+ const { assignmentId } = input;
1302
+
1303
+ // Check if assignment exists and user is a teacher of the class
1304
+ const assignment = await prisma.assignment.findFirst({
1305
+ where: {
1306
+ id: assignmentId,
1307
+ class: {
1308
+ teachers: {
1309
+ some: { id: ctx.user.id },
1310
+ },
1311
+ },
1312
+ },
1313
+ });
1314
+
1315
+ if (!assignment) {
1316
+ throw new TRPCError({
1317
+ code: "NOT_FOUND",
1318
+ message: "Assignment not found or you are not authorized",
1319
+ });
1320
+ }
1321
+
1322
+ // Detach assignment from event
1323
+ const updatedAssignment = await prisma.assignment.update({
1324
+ where: { id: assignmentId },
1325
+ data: {
1326
+ eventAttached: {
1327
+ disconnect: true
1328
+ }
1329
+ },
1330
+ include: {
1331
+ attachments: {
1332
+ select: {
1333
+ id: true,
1334
+ name: true,
1335
+ type: true,
1336
+ }
1337
+ },
1338
+ section: {
1339
+ select: {
1340
+ id: true,
1341
+ name: true
1342
+ }
1343
+ },
1344
+ teacher: {
1345
+ select: {
1346
+ id: true,
1347
+ username: true
1348
+ }
1349
+ },
1350
+ eventAttached: {
1351
+ select: {
1352
+ id: true,
1353
+ name: true,
1354
+ startTime: true,
1355
+ endTime: true,
1356
+ }
1357
+ }
1358
+ }
1359
+ });
1360
+
1361
+ return { assignment: updatedAssignment };
1362
+ }),
1363
+
1364
+ getAvailableEvents: protectedTeacherProcedure
1365
+ .input(z.object({
1366
+ assignmentId: z.string(),
1367
+ }))
1368
+ .query(async ({ ctx, input }) => {
1369
+ if (!ctx.user) {
1370
+ throw new TRPCError({
1371
+ code: "UNAUTHORIZED",
1372
+ message: "User must be authenticated",
1373
+ });
1374
+ }
1375
+
1376
+ const { assignmentId } = input;
1377
+
1378
+ // Get the assignment to find the class
1379
+ const assignment = await prisma.assignment.findFirst({
1380
+ where: {
1381
+ id: assignmentId,
1382
+ class: {
1383
+ teachers: {
1384
+ some: { id: ctx.user.id },
1385
+ },
1386
+ },
1387
+ },
1388
+ select: { classId: true }
1389
+ });
1390
+
1391
+ if (!assignment) {
1392
+ throw new TRPCError({
1393
+ code: "NOT_FOUND",
1394
+ message: "Assignment not found or you are not authorized",
1395
+ });
1396
+ }
1397
+
1398
+ // Get all events for the class that don't already have this assignment attached
1399
+ const events = await prisma.event.findMany({
1400
+ where: {
1401
+ classId: assignment.classId,
1402
+ assignmentsAttached: {
1403
+ none: {
1404
+ id: assignmentId
1405
+ }
1406
+ }
1407
+ },
1408
+ select: {
1409
+ id: true,
1410
+ name: true,
1411
+ startTime: true,
1412
+ endTime: true,
1413
+ location: true,
1414
+ remarks: true,
1415
+ },
1416
+ orderBy: {
1417
+ startTime: 'asc'
1418
+ }
1419
+ });
1420
+
1421
+ return { events };
1422
+ }),
1423
+
1424
+ dueToday: protectedProcedure
1425
+ .query(async ({ ctx }) => {
1426
+ if (!ctx.user) {
1427
+ throw new TRPCError({
1428
+ code: "UNAUTHORIZED",
1429
+ message: "User must be authenticated",
1430
+ });
1431
+ }
1432
+
1433
+ const assignments = await prisma.assignment.findMany({
1434
+ where: {
1435
+ dueDate: {
1436
+ equals: new Date(),
1437
+ },
1438
+ },
1439
+ select: {
1440
+ id: true,
1441
+ title: true,
1442
+ dueDate: true,
1443
+ type: true,
1444
+ graded: true,
1445
+ maxGrade: true,
1446
+ class: {
1447
+ select: {
1448
+ id: true,
1449
+ name: true,
1450
+ }
1451
+ }
1452
+ }
1453
+ });
1454
+
1455
+ return assignments.map(assignment => ({
1456
+ ...assignment,
1457
+ dueDate: assignment.dueDate.toISOString(),
1458
+ }));
1459
+ }),
1460
+ attachMarkScheme: protectedTeacherProcedure
1461
+ .input(z.object({
1462
+ assignmentId: z.string(),
1463
+ markSchemeId: z.string().nullable(),
1464
+ }))
1465
+ .mutation(async ({ ctx, input }) => {
1466
+ const { assignmentId, markSchemeId } = input;
1467
+
1468
+ const assignment = await prisma.assignment.findFirst({
1469
+ where: {
1470
+ id: assignmentId,
1471
+ },
1472
+ });
1473
+
1474
+ if (!assignment) {
1475
+ throw new TRPCError({
1476
+ code: "NOT_FOUND",
1477
+ message: "Assignment not found",
1478
+ });
1479
+ }
1480
+
1481
+ // If markSchemeId is provided, verify it exists
1482
+ if (markSchemeId) {
1483
+ const markScheme = await prisma.markScheme.findFirst({
1484
+ where: {
1485
+ id: markSchemeId,
1486
+ },
1487
+ });
1488
+
1489
+ if (!markScheme) {
1490
+ throw new TRPCError({
1491
+ code: "NOT_FOUND",
1492
+ message: "Mark scheme not found",
1493
+ });
1494
+ }
1495
+ }
1496
+
1497
+ const updatedAssignment = await prisma.assignment.update({
1498
+ where: { id: assignmentId },
1499
+ data: {
1500
+ markScheme: markSchemeId ? {
1501
+ connect: { id: markSchemeId },
1502
+ } : {
1503
+ disconnect: true,
1504
+ },
1505
+ },
1506
+ include: {
1507
+ attachments: true,
1508
+ section: true,
1509
+ teacher: true,
1510
+ eventAttached: true,
1511
+ markScheme: true,
1512
+ },
1513
+ });
1514
+
1515
+ return updatedAssignment;
1516
+ }),
1517
+ detachMarkScheme: protectedTeacherProcedure
1518
+ .input(z.object({
1519
+ assignmentId: z.string(),
1520
+ }))
1521
+ .mutation(async ({ ctx, input }) => {
1522
+ const { assignmentId } = input;
1523
+
1524
+ const assignment = await prisma.assignment.findFirst({
1525
+ where: {
1526
+ id: assignmentId,
1527
+ },
1528
+ });
1529
+
1530
+ if (!assignment) {
1531
+ throw new TRPCError({
1532
+ code: "NOT_FOUND",
1533
+ message: "Assignment not found",
1534
+ });
1535
+ }
1536
+
1537
+ const updatedAssignment = await prisma.assignment.update({
1538
+ where: { id: assignmentId },
1539
+ data: {
1540
+ markScheme: {
1541
+ disconnect: true,
1542
+ },
1543
+ },
1544
+ include: {
1545
+ attachments: true,
1546
+ section: true,
1547
+ teacher: true,
1548
+ eventAttached: true,
1549
+ markScheme: true,
1550
+ },
1551
+ });
1552
+
1553
+ return updatedAssignment;
1554
+ }),
1555
+
1556
+ attachGradingBoundary: protectedTeacherProcedure
1557
+ .input(z.object({
1558
+ assignmentId: z.string(),
1559
+ gradingBoundaryId: z.string().nullable(),
1560
+ }))
1561
+ .mutation(async ({ ctx, input }) => {
1562
+ const { assignmentId, gradingBoundaryId } = input;
1563
+
1564
+ const assignment = await prisma.assignment.findFirst({
1565
+ where: {
1566
+ id: assignmentId,
1567
+ },
1568
+ });
1569
+
1570
+ if (!assignment) {
1571
+ throw new TRPCError({
1572
+ code: "NOT_FOUND",
1573
+ message: "Assignment not found",
1574
+ });
1575
+ }
1576
+
1577
+ // If gradingBoundaryId is provided, verify it exists
1578
+ if (gradingBoundaryId) {
1579
+ const gradingBoundary = await prisma.gradingBoundary.findFirst({
1580
+ where: {
1581
+ id: gradingBoundaryId,
1582
+ },
1583
+ });
1584
+
1585
+ if (!gradingBoundary) {
1586
+ throw new TRPCError({
1587
+ code: "NOT_FOUND",
1588
+ message: "Grading boundary not found",
1589
+ });
1590
+ }
1591
+ }
1592
+
1593
+ const updatedAssignment = await prisma.assignment.update({
1594
+ where: { id: assignmentId },
1595
+ data: {
1596
+ gradingBoundary: gradingBoundaryId ? {
1597
+ connect: { id: gradingBoundaryId },
1598
+ } : {
1599
+ disconnect: true,
1600
+ },
1601
+ },
1602
+ include: {
1603
+ attachments: true,
1604
+ section: true,
1605
+ teacher: true,
1606
+ eventAttached: true,
1607
+ gradingBoundary: true,
1608
+ },
1609
+ });
1610
+
1611
+ return updatedAssignment;
1612
+ }),
1613
+ });
1614
+