@studious-lms/server 1.2.31 → 1.2.33

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.
@@ -25,10 +25,13 @@ const createAssignmentSchema = z.object({
25
25
  dueDate: z.string(),
26
26
  files: z.array(directFileSchema).optional(), // Use direct file schema
27
27
  existingFileIds: z.array(z.string()).optional(),
28
+ aiPolicyLevel: z.number().default(0),
28
29
  acceptFiles: z.boolean().optional(),
29
30
  acceptExtendedResponse: z.boolean().optional(),
30
31
  acceptWorksheet: z.boolean().optional(),
32
+ worksheetIds: z.array(z.string()).optional(),
31
33
  gradeWithAI: z.boolean().optional(),
34
+ studentIds: z.array(z.string()).optional(),
32
35
  maxGrade: z.number().optional(),
33
36
  graded: z.boolean().optional(),
34
37
  weight: z.number().optional(),
@@ -46,10 +49,13 @@ const updateAssignmentSchema = z.object({
46
49
  instructions: z.string().optional(),
47
50
  dueDate: z.string().optional(),
48
51
  files: z.array(directFileSchema).optional(), // Use direct file schema
52
+ aiPolicyLevel: z.number().default(0),
49
53
  acceptFiles: z.boolean().optional(),
50
54
  acceptExtendedResponse: z.boolean().optional(),
51
55
  acceptWorksheet: z.boolean().optional(),
56
+ worksheetIds: z.array(z.string()).optional(),
52
57
  gradeWithAI: z.boolean().optional(),
58
+ studentIds: z.array(z.string()).optional(),
53
59
  existingFileIds: z.array(z.string()).optional(),
54
60
  removedAttachments: z.array(z.string()).optional(),
55
61
  maxGrade: z.number().optional(),
@@ -76,6 +82,7 @@ const submissionSchema = z.object({
76
82
  submissionId: z.string(),
77
83
  submit: z.boolean().optional(),
78
84
  newAttachments: z.array(directFileSchema).optional(), // Use direct file schema
85
+ extendedResponse: z.string().optional(),
79
86
  existingFileIds: z.array(z.string()).optional(),
80
87
  removedAttachments: z.array(z.string()).optional(),
81
88
  });
@@ -163,16 +170,42 @@ async function getUnifiedList(tx: any, classId: string) {
163
170
  }
164
171
 
165
172
  // Helper function to normalize unified list to 1..n
173
+ // Updated to batch updates to prevent timeouts with large lists
166
174
  async function normalizeUnifiedList(tx: any, classId: string, orderedItems: Array<{ id: string; type: 'section' | 'assignment' }>) {
167
- await Promise.all(
168
- orderedItems.map((item, index) => {
169
- if (item.type === 'section') {
170
- return tx.section.update({ where: { id: item.id }, data: { order: index + 1 } });
171
- } else {
172
- return tx.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
173
- }
174
- })
175
- );
175
+ const BATCH_SIZE = 10; // Process 10 items at a time to avoid overwhelming the transaction
176
+
177
+ // Group items by type for more efficient updates
178
+ const sections: Array<{ id: string; order: number }> = [];
179
+ const assignments: Array<{ id: string; order: number }> = [];
180
+
181
+ orderedItems.forEach((item, index) => {
182
+ const orderData = { id: item.id, order: index + 1 };
183
+ if (item.type === 'section') {
184
+ sections.push(orderData);
185
+ } else {
186
+ assignments.push(orderData);
187
+ }
188
+ });
189
+
190
+ // Process updates in batches
191
+ const processBatch = async (items: Array<{ id: string; order: number }>, type: 'section' | 'assignment') => {
192
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
193
+ const batch = items.slice(i, i + BATCH_SIZE);
194
+ await Promise.all(
195
+ batch.map(item => {
196
+ if (type === 'section') {
197
+ return tx.section.update({ where: { id: item.id }, data: { order: item.order } });
198
+ } else {
199
+ return tx.assignment.update({ where: { id: item.id }, data: { order: item.order } });
200
+ }
201
+ })
202
+ );
203
+ }
204
+ };
205
+
206
+ // Process sections and assignments sequentially to avoid transaction overload
207
+ await processBatch(sections, 'section');
208
+ await processBatch(assignments, 'assignment');
176
209
  }
177
210
 
178
211
  export const assignmentRouter = createTRPCRouter({
@@ -243,6 +276,9 @@ export const assignmentRouter = createTRPCRouter({
243
276
  await normalizeUnifiedList(tx, classId, next);
244
277
 
245
278
  return tx.assignment.findUnique({ where: { id: movedId } });
279
+ }, {
280
+ maxWait: 10000, // 10 seconds max wait time
281
+ timeout: 30000, // 30 seconds timeout for reordering operations
246
282
  });
247
283
 
248
284
  return result;
@@ -274,6 +310,9 @@ export const assignmentRouter = createTRPCRouter({
274
310
  await normalizeUnifiedList(tx, current.classId, unified.map(item => ({ id: item.id, type: item.type })));
275
311
 
276
312
  return tx.assignment.findUnique({ where: { id } });
313
+ }, {
314
+ maxWait: 10000, // 10 seconds max wait time
315
+ timeout: 30000, // 30 seconds timeout for reordering operations
277
316
  });
278
317
 
279
318
  return updated;
@@ -307,6 +346,9 @@ export const assignmentRouter = createTRPCRouter({
307
346
  // If frontend wants to change position, they should call reorder after move
308
347
 
309
348
  return tx.assignment.findUnique({ where: { id } });
349
+ }, {
350
+ maxWait: 5000, // 5 seconds max wait time
351
+ timeout: 10000, // 10 seconds timeout
310
352
  });
311
353
 
312
354
  return updated;
@@ -315,7 +357,7 @@ export const assignmentRouter = createTRPCRouter({
315
357
  create: protectedProcedure
316
358
  .input(createAssignmentSchema)
317
359
  .mutation(async ({ ctx, input }) => {
318
- const { classId, title, instructions, dueDate, files, existingFileIds, acceptFiles, acceptExtendedResponse, acceptWorksheet, gradeWithAI, maxGrade, graded, weight, sectionId, type, markSchemeId, gradingBoundaryId, inProgress } = input;
360
+ const { classId, title, instructions, dueDate, files, existingFileIds, aiPolicyLevel, acceptFiles, acceptExtendedResponse, acceptWorksheet, worksheetIds, gradeWithAI, studentIds, maxGrade, graded, weight, sectionId, type, markSchemeId, gradingBoundaryId, inProgress } = input;
319
361
 
320
362
  if (!ctx.user) {
321
363
  throw new TRPCError({
@@ -324,15 +366,23 @@ export const assignmentRouter = createTRPCRouter({
324
366
  });
325
367
  }
326
368
 
327
- // Get all students in the class
328
- const classData = await prisma.class.findUnique({
329
- where: { id: classId },
330
- include: {
331
- students: {
332
- select: { id: true }
369
+ // Pre-fetch data needed for the transaction (outside transaction scope)
370
+ const [classData, rubricData] = await Promise.all([
371
+ prisma.class.findUnique({
372
+ where: { id: classId },
373
+ include: {
374
+ students: {
375
+ select: { id: true }
376
+ }
333
377
  }
334
- }
335
- });
378
+ }),
379
+ markSchemeId ? prisma.markScheme.findUnique({
380
+ where: { id: markSchemeId },
381
+ select: {
382
+ structured: true,
383
+ }
384
+ }) : null
385
+ ]);
336
386
 
337
387
  if (!classData) {
338
388
  throw new TRPCError({
@@ -341,161 +391,198 @@ export const assignmentRouter = createTRPCRouter({
341
391
  });
342
392
  }
343
393
 
394
+ // Calculate max grade outside transaction
344
395
  let computedMaxGrade = maxGrade;
345
- if (markSchemeId) {
346
- const rubric = await prisma.markScheme.findUnique({
347
- where: { id: markSchemeId },
348
- select: {
349
- structured: true,
350
- }
351
- });
352
-
353
- const parsedRubric = JSON.parse(rubric?.structured || "{}");
354
-
355
- // Calculate max grade from rubric criteria levels
396
+ if (markSchemeId && rubricData) {
397
+ const parsedRubric = JSON.parse(rubricData.structured || "{}");
356
398
  computedMaxGrade = parsedRubric.criteria.reduce((acc: number, criterion: any) => {
357
399
  const maxPoints = Math.max(...criterion.levels.map((level: any) => level.points));
358
400
  return acc + maxPoints;
359
401
  }, 0);
360
402
  }
361
- console.log(markSchemeId, gradingBoundaryId);
362
403
 
363
- // Create assignment and place at top of its scope within a single transaction
364
- const teacherId = ctx.user!.id;
404
+ console.log(studentIds);
405
+
406
+ // Prepare submission data outside transaction
407
+ const submissionData = studentIds && studentIds.length > 0
408
+ ? studentIds.map((studentId) => ({
409
+ student: { connect: { id: studentId } }
410
+ }))
411
+ : classData.students.map((student) => ({
412
+ student: { connect: { id: student.id } }
413
+ }));
414
+
415
+ const teacherId = ctx.user.id;
416
+
417
+ // Minimal transaction - only for critical operations
365
418
  const assignment = await prisma.$transaction(async (tx) => {
419
+ // Create assignment with order 0 (will be at top)
366
420
  const created = await tx.assignment.create({
367
- data: {
368
- title,
369
- instructions,
370
- dueDate: new Date(dueDate),
371
- maxGrade: markSchemeId ? computedMaxGrade : maxGrade,
372
- graded,
373
- weight,
374
- type,
375
- acceptFiles,
376
- acceptExtendedResponse,
377
- acceptWorksheet,
378
- gradeWithAI,
379
- order: 1,
380
- inProgress: inProgress || false,
381
- class: {
382
- connect: { id: classId }
383
- },
384
- ...(sectionId && {
385
- section: {
386
- connect: { id: sectionId }
387
- }
388
- }),
389
- ...(markSchemeId && {
390
- markScheme: {
391
- connect: { id: markSchemeId }
392
- }
393
- }),
394
- ...(gradingBoundaryId && {
395
- gradingBoundary: {
396
- connect: { id: gradingBoundaryId }
397
- }
398
- }),
399
- submissions: {
400
- create: classData.students.map((student) => ({
401
- student: {
402
- connect: { id: student.id }
421
+ data: {
422
+ title,
423
+ instructions,
424
+ dueDate: new Date(dueDate),
425
+ maxGrade: markSchemeId ? computedMaxGrade : maxGrade,
426
+ graded,
427
+ weight,
428
+ type,
429
+ ...(aiPolicyLevel && { aiPolicyLevel }),
430
+ acceptFiles,
431
+ acceptExtendedResponse,
432
+ acceptWorksheet,
433
+ ...(worksheetIds && {
434
+ worksheets: {
435
+ connect: worksheetIds.map(id => ({ id }))
403
436
  }
404
- }))
405
- },
437
+ }),
438
+ gradeWithAI,
439
+ ...(studentIds && {
440
+ assignedTo: {
441
+ connect: studentIds.map(id => ({ id }))
442
+ }
443
+ }),
444
+ order: 0, // Set to 0 to place at top
445
+ inProgress: inProgress || false,
446
+ class: {
447
+ connect: { id: classId }
448
+ },
449
+ ...(sectionId && {
450
+ section: {
451
+ connect: { id: sectionId }
452
+ }
453
+ }),
454
+ ...(markSchemeId && {
455
+ markScheme: {
456
+ connect: { id: markSchemeId }
457
+ }
458
+ }),
459
+ ...(gradingBoundaryId && {
460
+ gradingBoundary: {
461
+ connect: { id: gradingBoundaryId }
462
+ }
463
+ }),
464
+ submissions: {
465
+ create: submissionData
466
+ },
406
467
  teacher: {
407
468
  connect: { id: teacherId }
408
469
  }
409
- },
410
- select: {
411
- id: true,
412
- title: true,
413
- instructions: true,
414
- dueDate: true,
415
- maxGrade: true,
416
- graded: true,
417
- weight: true,
418
- type: true,
419
- attachments: {
420
- select: {
421
- id: true,
422
- name: true,
423
- type: true,
424
- }
425
- },
426
- section: {
427
- select: {
428
- id: true,
429
- name: true
430
- }
431
470
  },
432
- teacher: {
433
- select: {
434
- id: true,
435
- username: true
471
+ select: {
472
+ id: true,
473
+ title: true,
474
+ instructions: true,
475
+ dueDate: true,
476
+ maxGrade: true,
477
+ graded: true,
478
+ weight: true,
479
+ type: true,
480
+ attachments: {
481
+ select: {
482
+ id: true,
483
+ name: true,
484
+ type: true,
485
+ }
486
+ },
487
+ section: {
488
+ select: {
489
+ id: true,
490
+ name: true
491
+ }
492
+ },
493
+ teacher: {
494
+ select: {
495
+ id: true,
496
+ username: true
497
+ }
498
+ },
499
+ class: {
500
+ select: {
501
+ id: true,
502
+ name: true
503
+ }
436
504
  }
505
+ }
506
+ });
507
+
508
+ // Optimized reordering - only increment existing items by 1
509
+ // This is much faster than reordering everything
510
+ await tx.assignment.updateMany({
511
+ where: {
512
+ classId: classId,
513
+ id: { not: created.id },
437
514
  },
438
- class: {
439
- select: {
440
- id: true,
441
- name: true
442
- }
515
+ data: {
516
+ order: { increment: 1 }
443
517
  }
518
+ });
519
+
520
+ await tx.section.updateMany({
521
+ where: {
522
+ classId: classId,
523
+ },
524
+ data: {
525
+ order: { increment: 1 }
444
526
  }
445
527
  });
446
528
 
447
- // Insert new assignment at top of unified list and normalize
448
- const unified = await getUnifiedList(tx, classId);
449
- const withoutNew = unified.filter(item => !(item.id === created.id && item.type === 'assignment'));
450
- const reindexed = [{ id: created.id, type: 'assignment' as const }, ...withoutNew.map(item => ({ id: item.id, type: item.type }))];
451
- await normalizeUnifiedList(tx, classId, reindexed);
529
+ // Update the created assignment to have order 1
530
+ await tx.assignment.update({
531
+ where: { id: created.id },
532
+ data: { order: 1 }
533
+ });
452
534
 
453
535
  return created;
536
+ }, {
537
+ maxWait: 10000, // Increased to 10 seconds max wait time
538
+ timeout: 20000, // Increased to 20 seconds timeout for safety
454
539
  });
455
540
 
456
- // NOTE: Files are now handled via direct upload endpoints
457
- // The files field in the schema is for metadata only
458
- // Actual file uploads should use getAssignmentUploadUrls endpoint
459
- let uploadedFiles: UploadedFile[] = [];
460
- if (files && files.length > 0) {
461
- // Create direct upload files instead of processing base64
462
- uploadedFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, assignment.id);
463
- }
541
+ // Handle file operations outside transaction
542
+ const fileOperations: Promise<any>[] = [];
464
543
 
465
- // Update assignment with new file attachments
466
- if (uploadedFiles.length > 0) {
467
- await prisma.assignment.update({
468
- where: { id: assignment.id },
469
- data: {
470
- attachments: {
471
- create: uploadedFiles.map(file => ({
472
- name: file.name,
473
- type: file.type,
474
- size: file.size,
475
- path: file.path,
476
- ...(file.thumbnailId && {
477
- thumbnail: {
478
- connect: { id: file.thumbnailId }
544
+ // Process direct upload files
545
+ if (files && files.length > 0) {
546
+ fileOperations.push(
547
+ createDirectUploadFiles(files, ctx.user.id, undefined, assignment.id)
548
+ .then(uploadedFiles => {
549
+ if (uploadedFiles.length > 0) {
550
+ return prisma.assignment.update({
551
+ where: { id: assignment.id },
552
+ data: {
553
+ attachments: {
554
+ create: uploadedFiles.map(file => ({
555
+ name: file.name,
556
+ type: file.type,
557
+ size: file.size,
558
+ path: file.path,
559
+ }))
560
+ }
479
561
  }
480
- })
481
- }))
482
- }
483
- }
484
- });
562
+ });
563
+ }
564
+ })
565
+ );
485
566
  }
486
567
 
487
- // Connect existing files if provided
568
+ // Connect existing files
488
569
  if (existingFileIds && existingFileIds.length > 0) {
489
- await prisma.assignment.update({
490
- where: { id: assignment.id },
491
- data: {
492
- attachments: {
493
- connect: existingFileIds.map(fileId => ({ id: fileId }))
570
+ fileOperations.push(
571
+ prisma.assignment.update({
572
+ where: { id: assignment.id },
573
+ data: {
574
+ attachments: {
575
+ connect: existingFileIds.map(fileId => ({ id: fileId }))
576
+ }
494
577
  }
495
- }
496
- });
578
+ })
579
+ );
497
580
  }
581
+
582
+ // Execute file operations in parallel
583
+ await Promise.all(fileOperations);
498
584
 
585
+ // Send notifications asynchronously (non-blocking)
499
586
  sendNotifications(classData.students.map(student => student.id), {
500
587
  title: `🔔 New assignment for ${classData.name}`,
501
588
  content:
@@ -503,7 +590,7 @@ export const assignmentRouter = createTRPCRouter({
503
590
  Due date: ${new Date(dueDate).toLocaleDateString()}.
504
591
  [Link to assignment](/class/${classId}/assignments/${assignment.id})`
505
592
  }).catch(error => {
506
- logger.error('Failed to send assignment notifications:');
593
+ logger.error('Failed to send assignment notifications:', error);
507
594
  });
508
595
 
509
596
  return assignment;
@@ -511,7 +598,7 @@ export const assignmentRouter = createTRPCRouter({
511
598
  update: protectedProcedure
512
599
  .input(updateAssignmentSchema)
513
600
  .mutation(async ({ ctx, input }) => {
514
- const { id, title, instructions, dueDate, files, existingFileIds, maxGrade, graded, weight, sectionId, type, inProgress, acceptFiles, acceptExtendedResponse, acceptWorksheet, gradeWithAI } = input;
601
+ const { id, title, instructions, dueDate, files, existingFileIds, worksheetIds, aiPolicyLevel, maxGrade, graded, weight, sectionId, type, inProgress, acceptFiles, acceptExtendedResponse, acceptWorksheet, gradeWithAI, studentIds } = input;
515
602
 
516
603
  if (!ctx.user) {
517
604
  throw new TRPCError({
@@ -520,36 +607,51 @@ export const assignmentRouter = createTRPCRouter({
520
607
  });
521
608
  }
522
609
 
523
- // Get the assignment with current attachments
524
- const assignment = await prisma.assignment.findFirst({
525
- where: {
526
- id,
527
- teacherId: ctx.user.id,
528
- },
529
- include: {
530
- attachments: {
531
- select: {
532
- id: true,
533
- name: true,
534
- type: true,
535
- path: true,
536
- size: true,
537
- uploadStatus: true,
538
- thumbnail: {
539
- select: {
540
- path: true
610
+ // Pre-fetch all necessary data outside transaction
611
+ const [assignment, classData] = await Promise.all([
612
+ prisma.assignment.findFirst({
613
+ where: {
614
+ id,
615
+ teacherId: ctx.user.id,
616
+ },
617
+ include: {
618
+ attachments: {
619
+ select: {
620
+ id: true,
621
+ name: true,
622
+ type: true,
623
+ path: true,
624
+ size: true,
625
+ uploadStatus: true,
626
+ thumbnail: {
627
+ select: {
628
+ path: true
629
+ }
541
630
  }
631
+ },
632
+ },
633
+ class: {
634
+ select: {
635
+ id: true,
636
+ name: true
542
637
  }
543
638
  },
639
+ markScheme: true,
544
640
  },
545
- class: {
546
- select: {
547
- id: true,
548
- name: true
549
- }
641
+ }),
642
+ prisma.class.findFirst({
643
+ where: {
644
+ assignments: {
645
+ some: { id }
646
+ }
550
647
  },
551
- },
552
- });
648
+ include: {
649
+ students: {
650
+ select: { id: true }
651
+ }
652
+ }
653
+ })
654
+ ]);
553
655
 
554
656
  if (!assignment) {
555
657
  throw new TRPCError({
@@ -558,150 +660,190 @@ export const assignmentRouter = createTRPCRouter({
558
660
  });
559
661
  }
560
662
 
561
- // NOTE: Files are now handled via direct upload endpoints
562
- let uploadedFiles: UploadedFile[] = [];
563
- if (files && files.length > 0) {
564
- // Create direct upload files instead of processing base64
565
- uploadedFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, input.id);
566
- }
663
+ // Prepare submission data outside transaction if needed
664
+ const submissionData = studentIds && studentIds.length > 0
665
+ ? studentIds.map((studentId) => ({
666
+ student: { connect: { id: studentId } }
667
+ }))
668
+ : classData?.students.map((student) => ({
669
+ student: { connect: { id: student.id } }
670
+ }));
567
671
 
568
- // Delete removed attachments from storage before updating database
672
+ // Handle file deletion operations outside transaction
673
+ const fileDeletionPromises: Promise<void>[] = [];
569
674
  if (input.removedAttachments && input.removedAttachments.length > 0) {
570
675
  const filesToDelete = assignment.attachments.filter((file) =>
571
676
  input.removedAttachments!.includes(file.id)
572
677
  );
573
678
 
574
- // Delete files from storage (only if they were actually uploaded)
575
- await Promise.all(filesToDelete.map(async (file) => {
576
- try {
577
- // Only delete from GCS if the file was successfully uploaded
578
- if (file.uploadStatus === 'COMPLETED') {
579
- // Delete the main file
580
- await deleteFile(file.path);
581
-
582
- // Delete thumbnail if it exists
583
- if (file.thumbnail?.path) {
584
- await deleteFile(file.thumbnail.path);
585
- }
679
+ filesToDelete.forEach((file) => {
680
+ if (file.uploadStatus === 'COMPLETED') {
681
+ fileDeletionPromises.push(
682
+ deleteFile(file.path).catch(error => {
683
+ console.warn(`Failed to delete file ${file.path}:`, error);
684
+ })
685
+ );
686
+ if (file.thumbnail?.path) {
687
+ fileDeletionPromises.push(
688
+ deleteFile(file.thumbnail.path).catch(error => {
689
+ console.warn(`Failed to delete thumbnail ${file.thumbnail!.path}:`, error);
690
+ })
691
+ );
586
692
  }
587
- } catch (error) {
588
- console.warn(`Failed to delete file ${file.path}:`, error);
589
693
  }
590
- }));
694
+ });
591
695
  }
592
696
 
593
- // Update assignment
594
- const updatedAssignment = await prisma.assignment.update({
595
- where: { id },
596
- data: {
597
- ...(title && { title }),
598
- ...(instructions && { instructions }),
599
- ...(dueDate && { dueDate: new Date(dueDate) }),
600
- ...(maxGrade && { maxGrade }),
601
- ...(graded !== undefined && { graded }),
602
- ...(weight && { weight }),
603
- ...(type && { type }),
604
- ...(inProgress !== undefined && { inProgress }),
605
- ...(acceptFiles !== undefined && { acceptFiles }),
606
- ...(acceptExtendedResponse !== undefined && { acceptExtendedResponse }),
607
- ...(acceptWorksheet !== undefined && { acceptWorksheet }),
608
- ...(gradeWithAI !== undefined && { gradeWithAI }),
609
- ...(sectionId !== undefined && {
610
- section: sectionId ? {
611
- connect: { id: sectionId }
612
- } : {
613
- disconnect: true
614
- }
615
- }),
616
- ...(uploadedFiles.length > 0 && {
617
- attachments: {
618
- create: uploadedFiles.map(file => ({
619
- name: file.name,
620
- type: file.type,
621
- size: file.size,
622
- path: file.path,
623
- ...(file.thumbnailId && {
624
- thumbnail: {
625
- connect: { id: file.thumbnailId }
626
- }
627
- })
628
- }))
629
- }
630
- }),
631
- ...(existingFileIds && existingFileIds.length > 0 && {
632
- attachments: {
633
- connect: existingFileIds.map(fileId => ({ id: fileId }))
634
- }
635
- }),
636
- ...(input.removedAttachments && input.removedAttachments.length > 0 && {
637
- attachments: {
638
- deleteMany: {
639
- id: { in: input.removedAttachments }
697
+ // Execute file deletions in parallel
698
+ await Promise.all(fileDeletionPromises);
699
+
700
+ // Prepare file upload operations
701
+ let uploadedFiles: UploadedFile[] = [];
702
+ if (files && files.length > 0) {
703
+ uploadedFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, input.id);
704
+ }
705
+
706
+ // Minimal transaction for database update
707
+ const updatedAssignment = await prisma.$transaction(async (tx) => {
708
+ return tx.assignment.update({
709
+ where: { id },
710
+ data: {
711
+ ...(title && { title }),
712
+ ...(instructions && { instructions }),
713
+ ...(dueDate && { dueDate: new Date(dueDate) }),
714
+ ...(maxGrade && { maxGrade }),
715
+ ...(graded !== undefined && { graded }),
716
+ ...(weight && { weight }),
717
+ ...(type && { type }),
718
+ ...(inProgress !== undefined && { inProgress }),
719
+ ...(acceptFiles !== undefined && { acceptFiles }),
720
+ ...(acceptExtendedResponse !== undefined && { acceptExtendedResponse }),
721
+ ...(acceptWorksheet !== undefined && { acceptWorksheet }),
722
+ ...(gradeWithAI !== undefined && { gradeWithAI }),
723
+ ...(studentIds && {
724
+ assignedTo: {
725
+ set: [], // Clear existing
726
+ connect: studentIds.map(id => ({ id }))
640
727
  }
641
- }
642
- }),
643
- },
644
- select: {
645
- id: true,
646
- title: true,
647
- instructions: true,
648
- dueDate: true,
649
- maxGrade: true,
650
- graded: true,
651
- weight: true,
652
- type: true,
653
- createdAt: true,
654
- submissions: {
655
- select: {
656
- student: {
657
- select: {
658
- id: true,
659
- username: true
728
+ }),
729
+ ...(submissionData && {
730
+ submissions: {
731
+ deleteMany: {}, // Clear existing submissions
732
+ create: submissionData
733
+ }
734
+ }),
735
+ ...(aiPolicyLevel !== undefined && { aiPolicyLevel }),
736
+ ...(sectionId !== undefined && {
737
+ section: sectionId ? {
738
+ connect: { id: sectionId }
739
+ } : {
740
+ disconnect: true
741
+ }
742
+ }),
743
+ ...(worksheetIds && {
744
+ worksheets: {
745
+ set: [], // Clear existing
746
+ connect: worksheetIds.map(id => ({ id }))
747
+ }
748
+ }),
749
+ ...(uploadedFiles.length > 0 && {
750
+ attachments: {
751
+ create: uploadedFiles.map(file => ({
752
+ name: file.name,
753
+ type: file.type,
754
+ size: file.size,
755
+ path: file.path,
756
+ ...(file.thumbnailId && {
757
+ thumbnail: {
758
+ connect: { id: file.thumbnailId }
759
+ }
760
+ })
761
+ }))
762
+ }
763
+ }),
764
+ ...(existingFileIds && existingFileIds.length > 0 && {
765
+ attachments: {
766
+ connect: existingFileIds.map(fileId => ({ id: fileId }))
767
+ }
768
+ }),
769
+ ...(input.removedAttachments && input.removedAttachments.length > 0 && {
770
+ attachments: {
771
+ deleteMany: {
772
+ id: { in: input.removedAttachments }
660
773
  }
661
774
  }
662
- }
775
+ }),
663
776
  },
664
- attachments: {
665
- select: {
666
- id: true,
667
- name: true,
668
- type: true,
669
- thumbnail: true,
670
- size: true,
671
- path: true,
672
- uploadedAt: true,
673
- thumbnailId: true,
674
- }
675
- },
676
- section: true,
677
- teacher: true,
678
- class: true
679
- }
777
+ select: {
778
+ id: true,
779
+ title: true,
780
+ instructions: true,
781
+ dueDate: true,
782
+ maxGrade: true,
783
+ graded: true,
784
+ weight: true,
785
+ type: true,
786
+ createdAt: true,
787
+ markSchemeId: true,
788
+ submissions: {
789
+ select: {
790
+ student: {
791
+ select: {
792
+ id: true,
793
+ username: true
794
+ }
795
+ }
796
+ }
797
+ },
798
+ attachments: {
799
+ select: {
800
+ id: true,
801
+ name: true,
802
+ type: true,
803
+ thumbnail: true,
804
+ size: true,
805
+ path: true,
806
+ uploadedAt: true,
807
+ thumbnailId: true,
808
+ }
809
+ },
810
+ section: true,
811
+ teacher: true,
812
+ class: true
813
+ }
814
+ });
815
+ }, {
816
+ maxWait: 5000, // 5 seconds max wait time
817
+ timeout: 10000, // 10 seconds timeout
680
818
  });
681
819
 
682
-
683
- if (assignment.markSchemeId) {
684
- const rubric = await prisma.markScheme.findUnique({
685
- where: { id: assignment.markSchemeId },
820
+ // Handle rubric max grade calculation outside transaction
821
+ if (updatedAssignment.markSchemeId) {
822
+ prisma.markScheme.findUnique({
823
+ where: { id: updatedAssignment.markSchemeId },
686
824
  select: {
687
825
  structured: true,
688
826
  }
689
- });
690
- const parsedRubric = JSON.parse(rubric?.structured || "{}");
691
- const computedMaxGrade = parsedRubric.criteria.reduce((acc: number, criterion: any) => {
692
- const maxPoints = Math.max(...criterion.levels.map((level: any) => level.points));
693
- return acc + maxPoints;
694
- }, 0);
695
-
696
- await prisma.assignment.update({
697
- where: { id },
698
- data: {
699
- maxGrade: computedMaxGrade,
827
+ }).then(rubric => {
828
+ if (rubric) {
829
+ const parsedRubric = JSON.parse(rubric.structured || "{}");
830
+ const computedMaxGrade = parsedRubric.criteria?.reduce((acc: number, criterion: any) => {
831
+ const maxPoints = Math.max(...criterion.levels.map((level: any) => level.points));
832
+ return acc + maxPoints;
833
+ }, 0) || 0;
834
+
835
+ return prisma.assignment.update({
836
+ where: { id },
837
+ data: {
838
+ maxGrade: computedMaxGrade,
839
+ }
840
+ });
700
841
  }
842
+ }).catch(error => {
843
+ logger.error('Failed to update max grade from rubric:', error);
701
844
  });
702
845
  }
703
846
 
704
-
705
847
  return updatedAssignment;
706
848
  }),
707
849
 
@@ -838,6 +980,12 @@ export const assignmentRouter = createTRPCRouter({
838
980
  username: true
839
981
  }
840
982
  },
983
+ worksheets: {
984
+ select: {
985
+ id: true,
986
+ name: true,
987
+ }
988
+ },
841
989
  class: {
842
990
  select: {
843
991
  id: true,
@@ -927,6 +1075,12 @@ export const assignmentRouter = createTRPCRouter({
927
1075
  structured: true,
928
1076
  }
929
1077
  },
1078
+ worksheets: {
1079
+ select: {
1080
+ id: true,
1081
+ name: true,
1082
+ }
1083
+ },
930
1084
  gradingBoundary: {
931
1085
  select: {
932
1086
  id: true,
@@ -1039,6 +1193,12 @@ export const assignmentRouter = createTRPCRouter({
1039
1193
  id: true,
1040
1194
  structured: true,
1041
1195
  }
1196
+ },
1197
+ worksheets: {
1198
+ select: {
1199
+ id: true,
1200
+ name: true,
1201
+ }
1042
1202
  }
1043
1203
  },
1044
1204
  },
@@ -1068,7 +1228,7 @@ export const assignmentRouter = createTRPCRouter({
1068
1228
  });
1069
1229
  }
1070
1230
 
1071
- const { submissionId, submit, newAttachments, existingFileIds, removedAttachments } = input;
1231
+ const { submissionId, submit, newAttachments, existingFileIds, removedAttachments, extendedResponse } = input;
1072
1232
 
1073
1233
  const submission = await prisma.submission.findFirst({
1074
1234
  where: {
@@ -1238,6 +1398,7 @@ export const assignmentRouter = createTRPCRouter({
1238
1398
  },
1239
1399
  },
1240
1400
  }),
1401
+ ...(extendedResponse !== undefined && { extendedResponse }),
1241
1402
  },
1242
1403
  include: {
1243
1404
  attachments: {