@studious-lms/server 1.1.24 → 1.2.26

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 (49) hide show
  1. package/dist/lib/fileUpload.d.ts +2 -2
  2. package/dist/lib/fileUpload.d.ts.map +1 -1
  3. package/dist/lib/fileUpload.js +76 -14
  4. package/dist/lib/googleCloudStorage.d.ts +7 -0
  5. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  6. package/dist/lib/googleCloudStorage.js +19 -0
  7. package/dist/lib/notificationHandler.d.ts +25 -0
  8. package/dist/lib/notificationHandler.d.ts.map +1 -0
  9. package/dist/lib/notificationHandler.js +28 -0
  10. package/dist/routers/_app.d.ts +818 -78
  11. package/dist/routers/_app.d.ts.map +1 -1
  12. package/dist/routers/announcement.d.ts +290 -3
  13. package/dist/routers/announcement.d.ts.map +1 -1
  14. package/dist/routers/announcement.js +896 -10
  15. package/dist/routers/assignment.d.ts +70 -4
  16. package/dist/routers/assignment.d.ts.map +1 -1
  17. package/dist/routers/assignment.js +265 -131
  18. package/dist/routers/auth.js +1 -1
  19. package/dist/routers/file.d.ts +2 -0
  20. package/dist/routers/file.d.ts.map +1 -1
  21. package/dist/routers/file.js +9 -6
  22. package/dist/routers/labChat.d.ts.map +1 -1
  23. package/dist/routers/labChat.js +13 -5
  24. package/dist/routers/notifications.d.ts +8 -8
  25. package/dist/routers/section.d.ts +16 -0
  26. package/dist/routers/section.d.ts.map +1 -1
  27. package/dist/routers/section.js +139 -30
  28. package/dist/seedDatabase.d.ts +2 -2
  29. package/dist/seedDatabase.d.ts.map +1 -1
  30. package/dist/seedDatabase.js +2 -1
  31. package/dist/utils/logger.d.ts +1 -0
  32. package/dist/utils/logger.d.ts.map +1 -1
  33. package/dist/utils/logger.js +27 -2
  34. package/package.json +2 -2
  35. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +30 -0
  36. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +35 -0
  37. package/prisma/schema.prisma +50 -0
  38. package/src/lib/fileUpload.ts +79 -14
  39. package/src/lib/googleCloudStorage.ts +19 -0
  40. package/src/lib/notificationHandler.ts +36 -0
  41. package/src/routers/announcement.ts +1007 -10
  42. package/src/routers/assignment.ts +230 -82
  43. package/src/routers/auth.ts +1 -1
  44. package/src/routers/file.ts +10 -7
  45. package/src/routers/labChat.ts +15 -6
  46. package/src/routers/section.ts +158 -36
  47. package/src/seedDatabase.ts +2 -1
  48. package/src/utils/logger.ts +29 -2
  49. package/tests/setup.ts +3 -9
@@ -4,6 +4,8 @@ import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import { createDirectUploadFiles, type DirectUploadFile, confirmDirectUpload, updateUploadProgress, type UploadedFile } from "../lib/fileUpload.js";
6
6
  import { deleteFile } from "../lib/googleCloudStorage.js";
7
+ import { sendNotifications } from "../lib/notificationHandler.js";
8
+ import { logger } from "../utils/logger.js";
7
9
 
8
10
  // DEPRECATED: This schema is no longer used - files are uploaded directly to GCS
9
11
  // Use directFileSchema instead
@@ -130,7 +132,113 @@ const updateUploadProgressSchema = z.object({
130
132
  progress: z.number().min(0).max(100),
131
133
  });
132
134
 
135
+ // Helper function to get unified list of sections and assignments for a class
136
+ async function getUnifiedList(tx: any, classId: string) {
137
+ const [sections, assignments] = await Promise.all([
138
+ tx.section.findMany({
139
+ where: { classId },
140
+ select: { id: true, order: true },
141
+ }),
142
+ tx.assignment.findMany({
143
+ where: { classId },
144
+ select: { id: true, order: true },
145
+ }),
146
+ ]);
147
+
148
+ // Combine and sort by order
149
+ const unified = [
150
+ ...sections.map((s: any) => ({ id: s.id, order: s.order, type: 'section' as const })),
151
+ ...assignments.map((a: any) => ({ id: a.id, order: a.order, type: 'assignment' as const })),
152
+ ].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
153
+
154
+ return unified;
155
+ }
156
+
157
+ // Helper function to normalize unified list to 1..n
158
+ async function normalizeUnifiedList(tx: any, classId: string, orderedItems: Array<{ id: string; type: 'section' | 'assignment' }>) {
159
+ await Promise.all(
160
+ orderedItems.map((item, index) => {
161
+ if (item.type === 'section') {
162
+ return tx.section.update({ where: { id: item.id }, data: { order: index + 1 } });
163
+ } else {
164
+ return tx.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
165
+ }
166
+ })
167
+ );
168
+ }
169
+
133
170
  export const assignmentRouter = createTRPCRouter({
171
+ // Reorder an assignment within the unified list (sections + assignments)
172
+ reorder: protectedTeacherProcedure
173
+ .input(z.object({
174
+ classId: z.string(),
175
+ movedId: z.string(),
176
+ // One of: place at start/end of unified list, or relative to targetId (can be section or assignment)
177
+ position: z.enum(['start', 'end', 'before', 'after']),
178
+ targetId: z.string().optional(), // Can be a section ID or assignment ID
179
+ }))
180
+ .mutation(async ({ ctx, input }) => {
181
+ const { classId, movedId, position, targetId } = input;
182
+
183
+ const moved = await prisma.assignment.findFirst({
184
+ where: { id: movedId, classId },
185
+ select: { id: true, classId: true },
186
+ });
187
+
188
+ if (!moved) {
189
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
190
+ }
191
+
192
+ if ((position === 'before' || position === 'after') && !targetId) {
193
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId required for before/after' });
194
+ }
195
+
196
+ const result = await prisma.$transaction(async (tx) => {
197
+ const unified = await getUnifiedList(tx, classId);
198
+
199
+ // Find moved item and target in unified list
200
+ const movedIdx = unified.findIndex(item => item.id === movedId && item.type === 'assignment');
201
+ if (movedIdx === -1) {
202
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found in unified list' });
203
+ }
204
+
205
+ // Build list without moved item
206
+ const withoutMoved = unified.filter(item => !(item.id === movedId && item.type === 'assignment'));
207
+
208
+ let next: Array<{ id: string; type: 'section' | 'assignment' }> = [];
209
+
210
+ if (position === 'start') {
211
+ next = [{ id: movedId, type: 'assignment' }, ...withoutMoved.map(item => ({ id: item.id, type: item.type }))];
212
+ } else if (position === 'end') {
213
+ next = [...withoutMoved.map(item => ({ id: item.id, type: item.type })), { id: movedId, type: 'assignment' }];
214
+ } else {
215
+ const targetIdx = withoutMoved.findIndex(item => item.id === targetId);
216
+ if (targetIdx === -1) {
217
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId not found in unified list' });
218
+ }
219
+ if (position === 'before') {
220
+ next = [
221
+ ...withoutMoved.slice(0, targetIdx).map(item => ({ id: item.id, type: item.type })),
222
+ { id: movedId, type: 'assignment' },
223
+ ...withoutMoved.slice(targetIdx).map(item => ({ id: item.id, type: item.type })),
224
+ ];
225
+ } else {
226
+ next = [
227
+ ...withoutMoved.slice(0, targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
228
+ { id: movedId, type: 'assignment' },
229
+ ...withoutMoved.slice(targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
230
+ ];
231
+ }
232
+ }
233
+
234
+ // Normalize to 1..n
235
+ await normalizeUnifiedList(tx, classId, next);
236
+
237
+ return tx.assignment.findUnique({ where: { id: movedId } });
238
+ });
239
+
240
+ return result;
241
+ }),
134
242
  order: protectedTeacherProcedure
135
243
  .input(z.object({
136
244
  id: z.string(),
@@ -138,50 +246,62 @@ export const assignmentRouter = createTRPCRouter({
138
246
  order: z.number(),
139
247
  }))
140
248
  .mutation(async ({ ctx, input }) => {
249
+ // Deprecated: prefer `reorder`. For backward-compatibility, set the order then normalize unified list.
141
250
  const { id, order } = input;
142
251
 
143
- const assignment = await prisma.assignment.update({
252
+ const current = await prisma.assignment.findUnique({
144
253
  where: { id },
145
- data: { order },
254
+ select: { id: true, classId: true },
146
255
  });
147
256
 
148
- return assignment;
257
+ if (!current) {
258
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
259
+ }
260
+
261
+ const updated = await prisma.$transaction(async (tx) => {
262
+ await tx.assignment.update({ where: { id }, data: { order } });
263
+
264
+ // Normalize entire unified list
265
+ const unified = await getUnifiedList(tx, current.classId);
266
+ await normalizeUnifiedList(tx, current.classId, unified.map(item => ({ id: item.id, type: item.type })));
267
+
268
+ return tx.assignment.findUnique({ where: { id } });
269
+ });
270
+
271
+ return updated;
149
272
  }),
150
273
 
151
274
  move: protectedTeacherProcedure
152
275
  .input(z.object({
153
276
  id: z.string(),
154
277
  classId: z.string(),
155
- targetSectionId: z.string(),
278
+ targetSectionId: z.string().nullable().optional(),
156
279
  }))
157
280
  .mutation(async ({ ctx, input }) => {
158
- const { id, targetSectionId } = input;
159
-
281
+ const { id } = input;
282
+ const targetSectionId = (input.targetSectionId ?? null) || null; // normalize empty string to null
160
283
 
161
- const assignments = await prisma.assignment.findMany({
162
- where: { sectionId: targetSectionId },
284
+ const moved = await prisma.assignment.findUnique({
285
+ where: { id },
286
+ select: { id: true, classId: true, sectionId: true },
163
287
  });
164
288
 
165
- const stack = assignments.sort((a, b) => (a.order || 0) - (b.order || 0)).map((assignment, index) => ({
166
- id: assignment.id,
167
- order: index + 1,
168
- })).map((assignment) => ({
169
- where: { id: assignment.id },
170
- data: { order: assignment.order },
171
- }));
289
+ if (!moved) {
290
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
291
+ }
172
292
 
173
- await Promise.all(
174
- stack.map(({ where, data }) =>
175
- prisma.assignment.update({ where, data })
176
- )
177
- );
293
+ const updated = await prisma.$transaction(async (tx) => {
294
+ // Update sectionId first
295
+ await tx.assignment.update({ where: { id }, data: { sectionId: targetSectionId } });
178
296
 
179
- const assignment = await prisma.assignment.update({
180
- where: { id },
181
- data: { sectionId: targetSectionId, order: 0 },
297
+ // The unified list ordering remains the same, just the assignment's sectionId changed
298
+ // No need to reorder since we're keeping the same position in the unified list
299
+ // If frontend wants to change position, they should call reorder after move
300
+
301
+ return tx.assignment.findUnique({ where: { id } });
182
302
  });
183
303
 
184
- return assignment;
304
+ return updated;
185
305
  }),
186
306
 
187
307
  create: protectedProcedure
@@ -232,26 +352,10 @@ export const assignmentRouter = createTRPCRouter({
232
352
  }
233
353
  console.log(markSchemeId, gradingBoundaryId);
234
354
 
235
- // find all assignments in the section it is in (or none) and reorder them
236
- const assignments = await prisma.assignment.findMany({
237
- where: {
238
- classId: classId,
239
- ...(sectionId && {
240
- sectionId: sectionId,
241
- }),
242
- },
243
- });
244
-
245
- const stack = assignments.sort((a, b) => (a.order || 0) - (b.order || 0)).map((assignment, index) => ({
246
- id: assignment.id,
247
- order: index + 1,
248
- })).map((assignment) => ({
249
- where: { id: assignment.id },
250
- data: { order: assignment.order },
251
- }));
252
-
253
- // Create assignment with submissions for all students
254
- const assignment = await prisma.assignment.create({
355
+ // Create assignment and place at top of its scope within a single transaction
356
+ const teacherId = ctx.user!.id;
357
+ const assignment = await prisma.$transaction(async (tx) => {
358
+ const created = await tx.assignment.create({
255
359
  data: {
256
360
  title,
257
361
  instructions,
@@ -260,7 +364,7 @@ export const assignmentRouter = createTRPCRouter({
260
364
  graded,
261
365
  weight,
262
366
  type,
263
- order: 0,
367
+ order: 1,
264
368
  inProgress: inProgress || false,
265
369
  class: {
266
370
  connect: { id: classId }
@@ -287,11 +391,11 @@ export const assignmentRouter = createTRPCRouter({
287
391
  }
288
392
  }))
289
393
  },
290
- teacher: {
291
- connect: { id: ctx.user.id }
292
- }
394
+ teacher: {
395
+ connect: { id: teacherId }
396
+ }
293
397
  },
294
- select: {
398
+ select: {
295
399
  id: true,
296
400
  title: true,
297
401
  instructions: true,
@@ -325,14 +429,17 @@ export const assignmentRouter = createTRPCRouter({
325
429
  name: true
326
430
  }
327
431
  }
328
- }
329
- });
432
+ }
433
+ });
330
434
 
331
- await Promise.all(
332
- stack.map(({ where, data }) =>
333
- prisma.assignment.update({ where, data })
334
- )
335
- );
435
+ // Insert new assignment at top of unified list and normalize
436
+ const unified = await getUnifiedList(tx, classId);
437
+ const withoutNew = unified.filter(item => !(item.id === created.id && item.type === 'assignment'));
438
+ const reindexed = [{ id: created.id, type: 'assignment' as const }, ...withoutNew.map(item => ({ id: item.id, type: item.type }))];
439
+ await normalizeUnifiedList(tx, classId, reindexed);
440
+
441
+ return created;
442
+ });
336
443
 
337
444
  // NOTE: Files are now handled via direct upload endpoints
338
445
  // The files field in the schema is for metadata only
@@ -376,6 +483,16 @@ export const assignmentRouter = createTRPCRouter({
376
483
  }
377
484
  });
378
485
  }
486
+
487
+ sendNotifications(classData.students.map(student => student.id), {
488
+ title: `🔔 New assignment for ${classData.name}`,
489
+ content:
490
+ `The assignment "${title}" has been created in ${classData.name}.\n
491
+ Due date: ${new Date(dueDate).toLocaleDateString()}.
492
+ [Link to assignment](/class/${classId}/assignments/${assignment.id})`
493
+ }).catch(error => {
494
+ logger.error('Failed to send assignment notifications:');
495
+ });
379
496
 
380
497
  return assignment;
381
498
  }),
@@ -405,6 +522,7 @@ export const assignmentRouter = createTRPCRouter({
405
522
  type: true,
406
523
  path: true,
407
524
  size: true,
525
+ uploadStatus: true,
408
526
  thumbnail: {
409
527
  select: {
410
528
  path: true
@@ -435,6 +553,31 @@ export const assignmentRouter = createTRPCRouter({
435
553
  uploadedFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, input.id);
436
554
  }
437
555
 
556
+ // Delete removed attachments from storage before updating database
557
+ if (input.removedAttachments && input.removedAttachments.length > 0) {
558
+ const filesToDelete = assignment.attachments.filter((file) =>
559
+ input.removedAttachments!.includes(file.id)
560
+ );
561
+
562
+ // Delete files from storage (only if they were actually uploaded)
563
+ await Promise.all(filesToDelete.map(async (file) => {
564
+ try {
565
+ // Only delete from GCS if the file was successfully uploaded
566
+ if (file.uploadStatus === 'COMPLETED') {
567
+ // Delete the main file
568
+ await deleteFile(file.path);
569
+
570
+ // Delete thumbnail if it exists
571
+ if (file.thumbnail?.path) {
572
+ await deleteFile(file.thumbnail.path);
573
+ }
574
+ }
575
+ } catch (error) {
576
+ console.warn(`Failed to delete file ${file.path}:`, error);
577
+ }
578
+ }));
579
+ }
580
+
438
581
  // Update assignment
439
582
  const updatedAssignment = await prisma.assignment.update({
440
583
  where: { id },
@@ -600,15 +743,18 @@ export const assignmentRouter = createTRPCRouter({
600
743
  ...assignment.submissions.flatMap(sub => [...sub.attachments, ...sub.annotations])
601
744
  ];
602
745
 
603
- // Delete files from storage
746
+ // Delete files from storage (only if they were actually uploaded)
604
747
  await Promise.all(filesToDelete.map(async (file) => {
605
748
  try {
606
- // Delete the main file
607
- await deleteFile(file.path);
749
+ // Only delete from GCS if the file was successfully uploaded
750
+ if (file.uploadStatus === 'COMPLETED') {
751
+ // Delete the main file
752
+ await deleteFile(file.path);
608
753
 
609
- // Delete thumbnail if it exists
610
- if (file.thumbnail) {
611
- await deleteFile(file.thumbnail.path);
754
+ // Delete thumbnail if it exists
755
+ if (file.thumbnail) {
756
+ await deleteFile(file.thumbnail.path);
757
+ }
612
758
  }
613
759
  } catch (error) {
614
760
  console.warn(`Failed to delete file ${file.path}:`, error);
@@ -753,6 +899,7 @@ export const assignmentRouter = createTRPCRouter({
753
899
  select: {
754
900
  id: true,
755
901
  username: true,
902
+ profile: true,
756
903
  },
757
904
  },
758
905
  assignment: {
@@ -859,12 +1006,7 @@ export const assignmentRouter = createTRPCRouter({
859
1006
  select: {
860
1007
  id: true,
861
1008
  username: true,
862
- profile: {
863
- select: {
864
- displayName: true,
865
- profilePicture: true,
866
- }
867
- }
1009
+ profile: true,
868
1010
  },
869
1011
  },
870
1012
  assignment: {
@@ -1050,15 +1192,18 @@ export const assignmentRouter = createTRPCRouter({
1050
1192
  removedAttachments.includes(file.id)
1051
1193
  );
1052
1194
 
1053
- // Delete files from storage
1195
+ // Delete files from storage (only if they were actually uploaded)
1054
1196
  await Promise.all(filesToDelete.map(async (file) => {
1055
1197
  try {
1056
- // Delete the main file
1057
- await deleteFile(file.path);
1058
-
1059
- // Delete thumbnail if it exists
1060
- if (file.thumbnail?.path) {
1061
- await deleteFile(file.thumbnail.path);
1198
+ // Only delete from GCS if the file was successfully uploaded
1199
+ if (file.uploadStatus === 'COMPLETED') {
1200
+ // Delete the main file
1201
+ await deleteFile(file.path);
1202
+
1203
+ // Delete thumbnail if it exists
1204
+ if (file.thumbnail?.path) {
1205
+ await deleteFile(file.thumbnail.path);
1206
+ }
1062
1207
  }
1063
1208
  } catch (error) {
1064
1209
  console.warn(`Failed to delete file ${file.path}:`, error);
@@ -1314,15 +1459,18 @@ export const assignmentRouter = createTRPCRouter({
1314
1459
  removedAttachments.includes(file.id)
1315
1460
  );
1316
1461
 
1317
- // Delete files from storage
1462
+ // Delete files from storage (only if they were actually uploaded)
1318
1463
  await Promise.all(filesToDelete.map(async (file) => {
1319
1464
  try {
1320
- // Delete the main file
1321
- await deleteFile(file.path);
1322
-
1323
- // Delete thumbnail if it exists
1324
- if (file.thumbnail?.path) {
1325
- await deleteFile(file.thumbnail.path);
1465
+ // Only delete from GCS if the file was successfully uploaded
1466
+ if (file.uploadStatus === 'COMPLETED') {
1467
+ // Delete the main file
1468
+ await deleteFile(file.path);
1469
+
1470
+ // Delete thumbnail if it exists
1471
+ if (file.thumbnail?.path) {
1472
+ await deleteFile(file.thumbnail.path);
1473
+ }
1326
1474
  }
1327
1475
  } catch (error) {
1328
1476
  console.warn(`Failed to delete file ${file.path}:`, error);
@@ -180,7 +180,7 @@ export const authRouter = createTRPCRouter({
180
180
  };
181
181
  }),
182
182
 
183
- logout: publicProcedure
183
+ logout: protectedProcedure
184
184
  .mutation(async ({ ctx }) => {
185
185
  if (!ctx.user) {
186
186
  throw new TRPCError({
@@ -328,14 +328,17 @@ export const fileRouter = createTRPCRouter({
328
328
  });
329
329
  }
330
330
 
331
- // Delete files from storage
331
+ // Delete files from storage (only if they were actually uploaded)
332
332
  try {
333
- // Delete the main file
334
- await deleteFile(file.path);
335
-
336
- // Delete thumbnail if it exists
337
- if (file.thumbnail) {
338
- await deleteFile(file.thumbnail.path);
333
+ // Only delete from GCS if the file was successfully uploaded
334
+ if (file.uploadStatus === 'COMPLETED') {
335
+ // Delete the main file
336
+ await deleteFile(file.path);
337
+
338
+ // Delete thumbnail if it exists
339
+ if (file.thumbnail) {
340
+ await deleteFile(file.thumbnail.path);
341
+ }
339
342
  }
340
343
  } catch (error) {
341
344
  logger.warn(`Failed to delete file ${file.path}:`, error as Record<string, any>);
@@ -10,8 +10,7 @@ import {
10
10
  } from '../utils/inference.js';
11
11
  import { logger } from '../utils/logger.js';
12
12
  import { isAIUser } from '../utils/aiUser.js';
13
- // DEPRECATED: uploadFile removed - use direct upload instead
14
- // import { uploadFile } from '../lib/googleCloudStorage.js';
13
+ import { bucket } from '../lib/googleCloudStorage.js';
15
14
  import { createPdf } from "../lib/jsonConversion.js"
16
15
  import OpenAI from 'openai';
17
16
  import { v4 as uuidv4 } from "uuid";
@@ -922,19 +921,29 @@ WHEN CREATING COURSE MATERIALS (docs field):
922
921
  .substring(0, 50);
923
922
 
924
923
  const filename = `${sanitizedTitle}_${uuidv4().substring(0, 8)}.pdf`;
925
-
924
+ const filePath = `class/generated/${fullLabChat.classId}/${filename}`;
926
925
 
927
926
  logger.info(`PDF ${i + 1} generated successfully`, { labChatId, title: doc.title });
928
- // DEPRECATED: Base64 upload removed - use direct upload instead
929
- // const gcpResult = await uploadFile(Buffer.from(pdfBytes).toString('base64'), `class/generated/${fullLabChat.classId}/${filename}`, 'application/pdf');
927
+
928
+ // Upload directly to Google Cloud Storage
929
+ const gcsFile = bucket.file(filePath);
930
+ await gcsFile.save(Buffer.from(pdfBytes), {
931
+ metadata: {
932
+ contentType: 'application/pdf',
933
+ }
934
+ });
935
+
930
936
  logger.info(`PDF ${i + 1} uploaded successfully`, { labChatId, filename });
931
937
 
932
938
  const file = await prisma.file.create({
933
939
  data: {
934
940
  name: filename,
935
- path: `class/generated/${fullLabChat.classId}/${filename}`,
941
+ path: filePath,
936
942
  type: 'application/pdf',
943
+ size: pdfBytes.length,
937
944
  userId: fullLabChat.createdById,
945
+ uploadStatus: 'COMPLETED',
946
+ uploadedAt: new Date(),
938
947
  },
939
948
  });
940
949
  attachmentIds.push(file.id);