@studious-lms/server 1.0.2 → 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.
- package/package.json +1 -6
- package/prisma/schema.prisma +228 -0
- package/src/exportType.ts +9 -0
- package/src/index.ts +94 -0
- package/src/lib/fileUpload.ts +163 -0
- package/src/lib/googleCloudStorage.ts +94 -0
- package/src/lib/prisma.ts +16 -0
- package/src/lib/thumbnailGenerator.ts +185 -0
- package/src/logger.ts +163 -0
- package/src/middleware/auth.ts +191 -0
- package/src/middleware/logging.ts +54 -0
- package/src/routers/_app.ts +34 -0
- package/src/routers/agenda.ts +79 -0
- package/src/routers/announcement.ts +134 -0
- package/src/routers/assignment.ts +1614 -0
- package/src/routers/attendance.ts +284 -0
- package/src/routers/auth.ts +286 -0
- package/src/routers/class.ts +753 -0
- package/src/routers/event.ts +509 -0
- package/src/routers/file.ts +96 -0
- package/src/routers/section.ts +138 -0
- package/src/routers/user.ts +82 -0
- package/src/socket/handlers.ts +143 -0
- package/src/trpc.ts +90 -0
- package/src/types/trpc.ts +15 -0
- package/src/utils/email.ts +11 -0
- package/src/utils/generateInviteCode.ts +8 -0
- package/src/utils/logger.ts +156 -0
- package/tsconfig.json +17 -0
- package/generated/prisma/client.d.ts +0 -1
- package/generated/prisma/client.js +0 -4
- package/generated/prisma/default.d.ts +0 -1
- package/generated/prisma/default.js +0 -4
- package/generated/prisma/edge.d.ts +0 -1
- package/generated/prisma/edge.js +0 -389
- package/generated/prisma/index-browser.js +0 -375
- package/generated/prisma/index.d.ts +0 -34865
- package/generated/prisma/index.js +0 -410
- package/generated/prisma/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/generated/prisma/package.json +0 -140
- package/generated/prisma/runtime/edge-esm.js +0 -34
- package/generated/prisma/runtime/edge.js +0 -34
- package/generated/prisma/runtime/index-browser.d.ts +0 -370
- package/generated/prisma/runtime/index-browser.js +0 -16
- package/generated/prisma/runtime/library.d.ts +0 -3647
- package/generated/prisma/runtime/library.js +0 -146
- package/generated/prisma/runtime/react-native.js +0 -83
- package/generated/prisma/runtime/wasm.js +0 -35
- package/generated/prisma/schema.prisma +0 -304
- package/generated/prisma/wasm.d.ts +0 -1
- 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
|
+
|