@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
@@ -2,6 +2,27 @@ import { z } from "zod";
2
2
  import { createTRPCRouter, protectedClassMemberProcedure, protectedTeacherProcedure, protectedProcedure } from "../trpc.js";
3
3
  import { prisma } from "../lib/prisma.js";
4
4
  import { TRPCError } from "@trpc/server";
5
+ import { sendNotifications } from "../lib/notificationHandler.js";
6
+ import { logger } from "../utils/logger.js";
7
+ import { createDirectUploadFiles, confirmDirectUpload } from "../lib/fileUpload.js";
8
+ import { deleteFile } from "../lib/googleCloudStorage.js";
9
+ // Schema for direct file uploads (no base64 data)
10
+ const directFileSchema = z.object({
11
+ name: z.string(),
12
+ type: z.string(),
13
+ size: z.number(),
14
+ });
15
+ // Schemas for file upload endpoints
16
+ const getAnnouncementUploadUrlsSchema = z.object({
17
+ announcementId: z.string(),
18
+ classId: z.string(),
19
+ files: z.array(directFileSchema),
20
+ });
21
+ const confirmAnnouncementUploadSchema = z.object({
22
+ fileId: z.string(),
23
+ uploadSuccess: z.boolean(),
24
+ errorMessage: z.string().optional(),
25
+ });
5
26
  const AnnouncementSelect = {
6
27
  id: true,
7
28
  teacher: {
@@ -12,6 +33,18 @@ const AnnouncementSelect = {
12
33
  },
13
34
  remarks: true,
14
35
  createdAt: true,
36
+ modifiedAt: true,
37
+ attachments: {
38
+ select: {
39
+ id: true,
40
+ name: true,
41
+ type: true,
42
+ size: true,
43
+ path: true,
44
+ uploadedAt: true,
45
+ thumbnailId: true,
46
+ },
47
+ },
15
48
  };
16
49
  export const announcementRouter = createTRPCRouter({
17
50
  getAll: protectedClassMemberProcedure
@@ -23,49 +56,152 @@ export const announcementRouter = createTRPCRouter({
23
56
  where: {
24
57
  classId: input.classId,
25
58
  },
26
- select: AnnouncementSelect,
59
+ select: {
60
+ ...AnnouncementSelect,
61
+ _count: {
62
+ select: {
63
+ comments: true,
64
+ },
65
+ },
66
+ },
27
67
  orderBy: {
28
68
  createdAt: 'desc',
29
69
  },
30
70
  });
71
+ // Transform to include comment count
72
+ const announcementsWithCounts = announcements.map(announcement => ({
73
+ ...announcement,
74
+ commentCount: announcement._count.comments,
75
+ _count: undefined,
76
+ }));
77
+ return {
78
+ announcements: announcementsWithCounts,
79
+ };
80
+ }),
81
+ get: protectedClassMemberProcedure
82
+ .input(z.object({
83
+ id: z.string(),
84
+ classId: z.string(),
85
+ }))
86
+ .query(async ({ ctx, input }) => {
87
+ const announcement = await prisma.announcement.findUnique({
88
+ where: {
89
+ id: input.id,
90
+ classId: input.classId,
91
+ },
92
+ select: AnnouncementSelect,
93
+ });
94
+ if (!announcement) {
95
+ throw new TRPCError({
96
+ code: "NOT_FOUND",
97
+ message: "Announcement not found",
98
+ });
99
+ }
31
100
  return {
32
- announcements,
101
+ announcement,
33
102
  };
34
103
  }),
35
104
  create: protectedTeacherProcedure
36
105
  .input(z.object({
37
106
  classId: z.string(),
38
- remarks: z.string(),
107
+ remarks: z.string().min(1, "Remarks cannot be empty"),
108
+ files: z.array(directFileSchema).optional(),
109
+ existingFileIds: z.array(z.string()).optional(),
39
110
  }))
40
111
  .mutation(async ({ ctx, input }) => {
112
+ const { classId, remarks, files, existingFileIds } = input;
113
+ if (!ctx.user) {
114
+ throw new TRPCError({
115
+ code: "UNAUTHORIZED",
116
+ message: "User must be authenticated",
117
+ });
118
+ }
119
+ const classData = await prisma.class.findUnique({
120
+ where: { id: classId },
121
+ include: {
122
+ students: {
123
+ select: { id: true }
124
+ }
125
+ }
126
+ });
127
+ if (!classData) {
128
+ throw new TRPCError({
129
+ code: "NOT_FOUND",
130
+ message: "Class not found",
131
+ });
132
+ }
41
133
  const announcement = await prisma.announcement.create({
42
134
  data: {
43
- remarks: input.remarks,
135
+ remarks: remarks,
44
136
  teacher: {
45
137
  connect: {
46
- id: ctx.user?.id,
138
+ id: ctx.user.id,
47
139
  },
48
140
  },
49
141
  class: {
50
142
  connect: {
51
- id: input.classId,
143
+ id: classId,
52
144
  },
53
145
  },
54
146
  },
55
147
  select: AnnouncementSelect,
56
148
  });
149
+ // Handle file attachments
150
+ // NOTE: Files are now handled via direct upload endpoints
151
+ // The files field in the schema is for metadata only
152
+ // Actual file uploads should use getAnnouncementUploadUrls endpoint
153
+ // However, if files are provided here, we create the file records and return upload URLs
154
+ let directUploadFiles = [];
155
+ if (files && files.length > 0) {
156
+ // Create direct upload files - this creates file records with upload URLs
157
+ // Files are automatically connected to the announcement via announcementId
158
+ directUploadFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, undefined, undefined, announcement.id);
159
+ }
160
+ // Connect existing files if provided
161
+ if (existingFileIds && existingFileIds.length > 0) {
162
+ await prisma.announcement.update({
163
+ where: { id: announcement.id },
164
+ data: {
165
+ attachments: {
166
+ connect: existingFileIds.map(fileId => ({ id: fileId }))
167
+ }
168
+ }
169
+ });
170
+ }
171
+ // Fetch announcement with attachments
172
+ const announcementWithAttachments = await prisma.announcement.findUnique({
173
+ where: { id: announcement.id },
174
+ select: AnnouncementSelect,
175
+ });
176
+ sendNotifications(classData.students.map(student => student.id), {
177
+ title: `🔔 Announcement for ${classData.name}`,
178
+ content: remarks
179
+ }).catch(error => {
180
+ logger.error('Failed to send announcement notifications:', error);
181
+ });
57
182
  return {
58
- announcement,
183
+ announcement: announcementWithAttachments || announcement,
184
+ // Return upload URLs if files were provided
185
+ uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
59
186
  };
60
187
  }),
61
188
  update: protectedProcedure
62
189
  .input(z.object({
63
190
  id: z.string(),
64
191
  data: z.object({
65
- content: z.string(),
192
+ remarks: z.string().min(1, "Remarks cannot be empty").optional(),
193
+ files: z.array(directFileSchema).optional(),
194
+ existingFileIds: z.array(z.string()).optional(),
195
+ removedAttachments: z.array(z.string()).optional(),
66
196
  }),
67
197
  }))
68
198
  .mutation(async ({ ctx, input }) => {
199
+ if (!ctx.user) {
200
+ throw new TRPCError({
201
+ code: "UNAUTHORIZED",
202
+ message: "User must be authenticated",
203
+ });
204
+ }
69
205
  const announcement = await prisma.announcement.findUnique({
70
206
  where: { id: input.id },
71
207
  include: {
@@ -74,6 +210,21 @@ export const announcementRouter = createTRPCRouter({
74
210
  teachers: true,
75
211
  },
76
212
  },
213
+ attachments: {
214
+ select: {
215
+ id: true,
216
+ name: true,
217
+ type: true,
218
+ path: true,
219
+ size: true,
220
+ uploadStatus: true,
221
+ thumbnail: {
222
+ select: {
223
+ path: true
224
+ }
225
+ }
226
+ },
227
+ },
77
228
  },
78
229
  });
79
230
  if (!announcement) {
@@ -82,19 +233,88 @@ export const announcementRouter = createTRPCRouter({
82
233
  message: "Announcement not found",
83
234
  });
84
235
  }
236
+ // Authorization check: user must be the creator OR a teacher in the class
237
+ const userId = ctx.user.id;
238
+ const isCreator = announcement.teacherId === userId;
239
+ const isClassTeacher = announcement.class.teachers.some((teacher) => teacher.id === userId);
240
+ if (!isCreator && !isClassTeacher) {
241
+ throw new TRPCError({
242
+ code: "FORBIDDEN",
243
+ message: "Only the announcement creator or class teachers can update announcements",
244
+ });
245
+ }
246
+ // Handle file attachments
247
+ // NOTE: Files are now handled via direct upload endpoints
248
+ let directUploadFiles = [];
249
+ if (input.data.files && input.data.files.length > 0) {
250
+ // Create direct upload files - this creates file records with upload URLs
251
+ // Files are automatically connected to the announcement via announcementId
252
+ directUploadFiles = await createDirectUploadFiles(input.data.files, userId, undefined, undefined, undefined, input.id);
253
+ }
254
+ // Delete removed attachments from storage before updating database
255
+ if (input.data.removedAttachments && input.data.removedAttachments.length > 0) {
256
+ const filesToDelete = announcement.attachments.filter((file) => input.data.removedAttachments.includes(file.id));
257
+ // Delete files from storage (only if they were actually uploaded)
258
+ await Promise.all(filesToDelete.map(async (file) => {
259
+ try {
260
+ // Only delete from GCS if the file was successfully uploaded
261
+ if (file.uploadStatus === 'COMPLETED') {
262
+ // Delete the main file
263
+ await deleteFile(file.path);
264
+ // Delete thumbnail if it exists
265
+ if (file.thumbnail?.path) {
266
+ await deleteFile(file.thumbnail.path);
267
+ }
268
+ }
269
+ }
270
+ catch (error) {
271
+ logger.warn(`Failed to delete file ${file.path}:`, {
272
+ error: error instanceof Error ? {
273
+ name: error.name,
274
+ message: error.message,
275
+ stack: error.stack,
276
+ } : error
277
+ });
278
+ }
279
+ }));
280
+ }
85
281
  const updatedAnnouncement = await prisma.announcement.update({
86
282
  where: { id: input.id },
87
283
  data: {
88
- remarks: input.data.content,
284
+ ...(input.data.remarks && { remarks: input.data.remarks }),
285
+ // Note: directUploadFiles are already connected via createDirectUploadFiles
286
+ ...(input.data.existingFileIds && input.data.existingFileIds.length > 0 && {
287
+ attachments: {
288
+ connect: input.data.existingFileIds.map(fileId => ({ id: fileId }))
289
+ }
290
+ }),
291
+ ...(input.data.removedAttachments && input.data.removedAttachments.length > 0 && {
292
+ attachments: {
293
+ deleteMany: {
294
+ id: { in: input.data.removedAttachments }
295
+ }
296
+ }
297
+ }),
89
298
  },
299
+ select: AnnouncementSelect,
90
300
  });
91
- return { announcement: updatedAnnouncement };
301
+ return {
302
+ announcement: updatedAnnouncement,
303
+ // Return upload URLs if new files were provided
304
+ uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
305
+ };
92
306
  }),
93
307
  delete: protectedProcedure
94
308
  .input(z.object({
95
309
  id: z.string(),
96
310
  }))
97
311
  .mutation(async ({ ctx, input }) => {
312
+ if (!ctx.user) {
313
+ throw new TRPCError({
314
+ code: "UNAUTHORIZED",
315
+ message: "User must be authenticated",
316
+ });
317
+ }
98
318
  const announcement = await prisma.announcement.findUnique({
99
319
  where: { id: input.id },
100
320
  include: {
@@ -111,9 +331,675 @@ export const announcementRouter = createTRPCRouter({
111
331
  message: "Announcement not found",
112
332
  });
113
333
  }
334
+ // Authorization check: user must be the creator OR a teacher in the class
335
+ const userId = ctx.user.id;
336
+ const isCreator = announcement.teacherId === userId;
337
+ const isClassTeacher = announcement.class.teachers.some((teacher) => teacher.id === userId);
338
+ if (!isCreator && !isClassTeacher) {
339
+ throw new TRPCError({
340
+ code: "FORBIDDEN",
341
+ message: "Only the announcement creator or class teachers can delete announcements",
342
+ });
343
+ }
114
344
  await prisma.announcement.delete({
115
345
  where: { id: input.id },
116
346
  });
117
347
  return { success: true };
118
348
  }),
349
+ getAnnouncementUploadUrls: protectedTeacherProcedure
350
+ .input(getAnnouncementUploadUrlsSchema)
351
+ .mutation(async ({ ctx, input }) => {
352
+ const { announcementId, classId, files } = input;
353
+ if (!ctx.user) {
354
+ throw new TRPCError({
355
+ code: "UNAUTHORIZED",
356
+ message: "You must be logged in to upload files",
357
+ });
358
+ }
359
+ // Verify user is a teacher of the class
360
+ const classData = await prisma.class.findFirst({
361
+ where: {
362
+ id: classId,
363
+ teachers: {
364
+ some: {
365
+ id: ctx.user.id,
366
+ },
367
+ },
368
+ },
369
+ });
370
+ if (!classData) {
371
+ throw new TRPCError({
372
+ code: "NOT_FOUND",
373
+ message: "Class not found or you are not a teacher",
374
+ });
375
+ }
376
+ // Verify announcement exists and belongs to the class
377
+ const announcement = await prisma.announcement.findFirst({
378
+ where: {
379
+ id: announcementId,
380
+ classId: classId,
381
+ },
382
+ });
383
+ if (!announcement) {
384
+ throw new TRPCError({
385
+ code: "NOT_FOUND",
386
+ message: "Announcement not found",
387
+ });
388
+ }
389
+ // Create direct upload files
390
+ const directUploadFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, // No specific directory
391
+ undefined, // No assignment ID
392
+ undefined, // No submission ID
393
+ announcementId);
394
+ return {
395
+ success: true,
396
+ uploadFiles: directUploadFiles,
397
+ };
398
+ }),
399
+ confirmAnnouncementUpload: protectedTeacherProcedure
400
+ .input(confirmAnnouncementUploadSchema)
401
+ .mutation(async ({ ctx, input }) => {
402
+ const { fileId, uploadSuccess, errorMessage } = input;
403
+ if (!ctx.user) {
404
+ throw new TRPCError({
405
+ code: "UNAUTHORIZED",
406
+ message: "You must be logged in",
407
+ });
408
+ }
409
+ // Verify file belongs to user and is an announcement file
410
+ const file = await prisma.file.findFirst({
411
+ where: {
412
+ id: fileId,
413
+ userId: ctx.user.id,
414
+ announcement: {
415
+ isNot: null,
416
+ },
417
+ },
418
+ });
419
+ if (!file) {
420
+ throw new TRPCError({
421
+ code: "NOT_FOUND",
422
+ message: "File not found or you don't have permission",
423
+ });
424
+ }
425
+ await confirmDirectUpload(fileId, uploadSuccess, errorMessage);
426
+ return {
427
+ success: true,
428
+ message: uploadSuccess ? "Upload confirmed successfully" : "Upload failed",
429
+ };
430
+ }),
431
+ // Comment endpoints
432
+ addComment: protectedClassMemberProcedure
433
+ .input(z.object({
434
+ announcementId: z.string(),
435
+ classId: z.string(),
436
+ content: z.string().min(1, "Comment cannot be empty"),
437
+ parentCommentId: z.string().optional(),
438
+ }))
439
+ .mutation(async ({ ctx, input }) => {
440
+ if (!ctx.user) {
441
+ throw new TRPCError({
442
+ code: "UNAUTHORIZED",
443
+ message: "User must be authenticated",
444
+ });
445
+ }
446
+ // Verify announcement exists and belongs to the class
447
+ const announcement = await prisma.announcement.findFirst({
448
+ where: {
449
+ id: input.announcementId,
450
+ classId: input.classId,
451
+ },
452
+ });
453
+ if (!announcement) {
454
+ throw new TRPCError({
455
+ code: "NOT_FOUND",
456
+ message: "Announcement not found",
457
+ });
458
+ }
459
+ // If replying to a comment, verify parent comment exists and belongs to the same announcement
460
+ if (input.parentCommentId) {
461
+ const parentComment = await prisma.announcementComment.findFirst({
462
+ where: {
463
+ id: input.parentCommentId,
464
+ announcementId: input.announcementId,
465
+ },
466
+ });
467
+ if (!parentComment) {
468
+ throw new TRPCError({
469
+ code: "NOT_FOUND",
470
+ message: "Parent comment not found",
471
+ });
472
+ }
473
+ }
474
+ const comment = await prisma.announcementComment.create({
475
+ data: {
476
+ content: input.content,
477
+ author: {
478
+ connect: { id: ctx.user.id },
479
+ },
480
+ announcement: {
481
+ connect: { id: input.announcementId },
482
+ },
483
+ ...(input.parentCommentId && {
484
+ parentComment: {
485
+ connect: { id: input.parentCommentId },
486
+ },
487
+ }),
488
+ },
489
+ include: {
490
+ author: {
491
+ select: {
492
+ id: true,
493
+ username: true,
494
+ profile: {
495
+ select: {
496
+ displayName: true,
497
+ profilePicture: true,
498
+ profilePictureThumbnail: true,
499
+ },
500
+ },
501
+ },
502
+ },
503
+ },
504
+ });
505
+ return { comment };
506
+ }),
507
+ updateComment: protectedProcedure
508
+ .input(z.object({
509
+ id: z.string(),
510
+ content: z.string().min(1, "Comment cannot be empty"),
511
+ }))
512
+ .mutation(async ({ ctx, input }) => {
513
+ if (!ctx.user) {
514
+ throw new TRPCError({
515
+ code: "UNAUTHORIZED",
516
+ message: "User must be authenticated",
517
+ });
518
+ }
519
+ const comment = await prisma.announcementComment.findUnique({
520
+ where: { id: input.id },
521
+ });
522
+ if (!comment) {
523
+ throw new TRPCError({
524
+ code: "NOT_FOUND",
525
+ message: "Comment not found",
526
+ });
527
+ }
528
+ // Only the author can update their comment
529
+ if (comment.authorId !== ctx.user.id) {
530
+ throw new TRPCError({
531
+ code: "FORBIDDEN",
532
+ message: "Only the comment author can update this comment",
533
+ });
534
+ }
535
+ const updatedComment = await prisma.announcementComment.update({
536
+ where: { id: input.id },
537
+ data: {
538
+ content: input.content,
539
+ },
540
+ include: {
541
+ author: {
542
+ select: {
543
+ id: true,
544
+ username: true,
545
+ profile: {
546
+ select: {
547
+ displayName: true,
548
+ profilePicture: true,
549
+ profilePictureThumbnail: true,
550
+ },
551
+ },
552
+ },
553
+ },
554
+ },
555
+ });
556
+ return { comment: updatedComment };
557
+ }),
558
+ deleteComment: protectedProcedure
559
+ .input(z.object({
560
+ id: z.string(),
561
+ }))
562
+ .mutation(async ({ ctx, input }) => {
563
+ if (!ctx.user) {
564
+ throw new TRPCError({
565
+ code: "UNAUTHORIZED",
566
+ message: "User must be authenticated",
567
+ });
568
+ }
569
+ const comment = await prisma.announcementComment.findUnique({
570
+ where: { id: input.id },
571
+ include: {
572
+ announcement: {
573
+ include: {
574
+ class: {
575
+ include: {
576
+ teachers: true,
577
+ },
578
+ },
579
+ },
580
+ },
581
+ },
582
+ });
583
+ if (!comment) {
584
+ throw new TRPCError({
585
+ code: "NOT_FOUND",
586
+ message: "Comment not found",
587
+ });
588
+ }
589
+ // Only the author or a class teacher can delete comments
590
+ const userId = ctx.user.id;
591
+ const isAuthor = comment.authorId === userId;
592
+ const isClassTeacher = comment.announcement.class.teachers.some((teacher) => teacher.id === userId);
593
+ if (!isAuthor && !isClassTeacher) {
594
+ throw new TRPCError({
595
+ code: "FORBIDDEN",
596
+ message: "Only the comment author or class teachers can delete comments",
597
+ });
598
+ }
599
+ await prisma.announcementComment.delete({
600
+ where: { id: input.id },
601
+ });
602
+ return { success: true };
603
+ }),
604
+ getComments: protectedClassMemberProcedure
605
+ .input(z.object({
606
+ announcementId: z.string(),
607
+ classId: z.string(),
608
+ }))
609
+ .query(async ({ ctx, input }) => {
610
+ // Verify announcement exists and belongs to the class
611
+ const announcement = await prisma.announcement.findFirst({
612
+ where: {
613
+ id: input.announcementId,
614
+ classId: input.classId,
615
+ },
616
+ });
617
+ if (!announcement) {
618
+ throw new TRPCError({
619
+ code: "NOT_FOUND",
620
+ message: "Announcement not found",
621
+ });
622
+ }
623
+ // Get all top-level comments (no parent)
624
+ const comments = await prisma.announcementComment.findMany({
625
+ where: {
626
+ announcementId: input.announcementId,
627
+ parentCommentId: null,
628
+ },
629
+ include: {
630
+ author: {
631
+ select: {
632
+ id: true,
633
+ username: true,
634
+ profile: {
635
+ select: {
636
+ displayName: true,
637
+ profilePicture: true,
638
+ profilePictureThumbnail: true,
639
+ },
640
+ },
641
+ },
642
+ },
643
+ replies: {
644
+ include: {
645
+ author: {
646
+ select: {
647
+ id: true,
648
+ username: true,
649
+ profile: {
650
+ select: {
651
+ displayName: true,
652
+ profilePicture: true,
653
+ profilePictureThumbnail: true,
654
+ },
655
+ },
656
+ },
657
+ },
658
+ },
659
+ orderBy: {
660
+ createdAt: 'asc',
661
+ },
662
+ },
663
+ },
664
+ orderBy: {
665
+ createdAt: 'asc',
666
+ },
667
+ });
668
+ return { comments };
669
+ }),
670
+ // Reaction endpoints
671
+ addReaction: protectedClassMemberProcedure
672
+ .input(z.object({
673
+ announcementId: z.string().optional(),
674
+ commentId: z.string().optional(),
675
+ classId: z.string(),
676
+ type: z.enum(['THUMBSUP', 'CELEBRATE', 'CARE', 'HEART', 'IDEA', 'HAPPY']),
677
+ }))
678
+ .mutation(async ({ ctx, input }) => {
679
+ if (!ctx.user) {
680
+ throw new TRPCError({
681
+ code: "UNAUTHORIZED",
682
+ message: "User must be authenticated",
683
+ });
684
+ }
685
+ // Exactly one of announcementId or commentId must be provided
686
+ if (!input.announcementId && !input.commentId) {
687
+ throw new TRPCError({
688
+ code: "BAD_REQUEST",
689
+ message: "Either announcementId or commentId must be provided",
690
+ });
691
+ }
692
+ if (input.announcementId && input.commentId) {
693
+ throw new TRPCError({
694
+ code: "BAD_REQUEST",
695
+ message: "Cannot react to both announcement and comment at the same time",
696
+ });
697
+ }
698
+ const userId = ctx.user.id;
699
+ // Verify the announcement or comment exists and belongs to the class
700
+ if (input.announcementId) {
701
+ const announcement = await prisma.announcement.findFirst({
702
+ where: {
703
+ id: input.announcementId,
704
+ classId: input.classId,
705
+ },
706
+ });
707
+ if (!announcement) {
708
+ throw new TRPCError({
709
+ code: "NOT_FOUND",
710
+ message: "Announcement not found",
711
+ });
712
+ }
713
+ // Upsert reaction: update if exists, create if not
714
+ const reaction = await prisma.reaction.upsert({
715
+ where: {
716
+ userId_announcementId: {
717
+ userId,
718
+ announcementId: input.announcementId,
719
+ },
720
+ },
721
+ update: {
722
+ type: input.type,
723
+ },
724
+ create: {
725
+ type: input.type,
726
+ userId,
727
+ announcementId: input.announcementId,
728
+ },
729
+ include: {
730
+ user: {
731
+ select: {
732
+ id: true,
733
+ username: true,
734
+ profile: {
735
+ select: {
736
+ displayName: true,
737
+ profilePicture: true,
738
+ profilePictureThumbnail: true,
739
+ },
740
+ },
741
+ },
742
+ },
743
+ },
744
+ });
745
+ return { reaction };
746
+ }
747
+ else if (input.commentId) {
748
+ // Verify comment exists and get its announcement to check class
749
+ const comment = await prisma.announcementComment.findUnique({
750
+ where: { id: input.commentId },
751
+ include: {
752
+ announcement: {
753
+ select: {
754
+ classId: true,
755
+ },
756
+ },
757
+ },
758
+ });
759
+ if (!comment) {
760
+ throw new TRPCError({
761
+ code: "NOT_FOUND",
762
+ message: "Comment not found",
763
+ });
764
+ }
765
+ if (comment.announcement.classId !== input.classId) {
766
+ throw new TRPCError({
767
+ code: "FORBIDDEN",
768
+ message: "Comment does not belong to this class",
769
+ });
770
+ }
771
+ // Upsert reaction: update if exists, create if not
772
+ const reaction = await prisma.reaction.upsert({
773
+ where: {
774
+ userId_commentId: {
775
+ userId,
776
+ commentId: input.commentId,
777
+ },
778
+ },
779
+ update: {
780
+ type: input.type,
781
+ },
782
+ create: {
783
+ type: input.type,
784
+ userId,
785
+ commentId: input.commentId,
786
+ },
787
+ include: {
788
+ user: {
789
+ select: {
790
+ id: true,
791
+ username: true,
792
+ profile: {
793
+ select: {
794
+ displayName: true,
795
+ profilePicture: true,
796
+ profilePictureThumbnail: true,
797
+ },
798
+ },
799
+ },
800
+ },
801
+ },
802
+ });
803
+ return { reaction };
804
+ }
805
+ throw new TRPCError({
806
+ code: "INTERNAL_SERVER_ERROR",
807
+ message: "Unexpected error",
808
+ });
809
+ }),
810
+ removeReaction: protectedProcedure
811
+ .input(z.object({
812
+ announcementId: z.string().optional(),
813
+ commentId: z.string().optional(),
814
+ }))
815
+ .mutation(async ({ ctx, input }) => {
816
+ if (!ctx.user) {
817
+ throw new TRPCError({
818
+ code: "UNAUTHORIZED",
819
+ message: "User must be authenticated",
820
+ });
821
+ }
822
+ // Exactly one of announcementId or commentId must be provided
823
+ if (!input.announcementId && !input.commentId) {
824
+ throw new TRPCError({
825
+ code: "BAD_REQUEST",
826
+ message: "Either announcementId or commentId must be provided",
827
+ });
828
+ }
829
+ const userId = ctx.user.id;
830
+ if (input.announcementId) {
831
+ const reaction = await prisma.reaction.findUnique({
832
+ where: {
833
+ userId_announcementId: {
834
+ userId,
835
+ announcementId: input.announcementId,
836
+ },
837
+ },
838
+ });
839
+ if (!reaction) {
840
+ throw new TRPCError({
841
+ code: "NOT_FOUND",
842
+ message: "Reaction not found",
843
+ });
844
+ }
845
+ await prisma.reaction.delete({
846
+ where: { id: reaction.id },
847
+ });
848
+ return { success: true };
849
+ }
850
+ else if (input.commentId) {
851
+ const reaction = await prisma.reaction.findUnique({
852
+ where: {
853
+ userId_commentId: {
854
+ userId,
855
+ commentId: input.commentId,
856
+ },
857
+ },
858
+ });
859
+ if (!reaction) {
860
+ throw new TRPCError({
861
+ code: "NOT_FOUND",
862
+ message: "Reaction not found",
863
+ });
864
+ }
865
+ await prisma.reaction.delete({
866
+ where: { id: reaction.id },
867
+ });
868
+ return { success: true };
869
+ }
870
+ throw new TRPCError({
871
+ code: "INTERNAL_SERVER_ERROR",
872
+ message: "Unexpected error",
873
+ });
874
+ }),
875
+ getReactions: protectedClassMemberProcedure
876
+ .input(z.object({
877
+ announcementId: z.string().optional(),
878
+ commentId: z.string().optional(),
879
+ classId: z.string(),
880
+ }))
881
+ .query(async ({ ctx, input }) => {
882
+ if (!ctx.user) {
883
+ throw new TRPCError({
884
+ code: "UNAUTHORIZED",
885
+ message: "User must be authenticated",
886
+ });
887
+ }
888
+ // Exactly one of announcementId or commentId must be provided
889
+ if (!input.announcementId && !input.commentId) {
890
+ throw new TRPCError({
891
+ code: "BAD_REQUEST",
892
+ message: "Either announcementId or commentId must be provided",
893
+ });
894
+ }
895
+ const userId = ctx.user.id;
896
+ if (input.announcementId) {
897
+ // Verify announcement exists
898
+ const announcement = await prisma.announcement.findFirst({
899
+ where: {
900
+ id: input.announcementId,
901
+ classId: input.classId,
902
+ },
903
+ });
904
+ if (!announcement) {
905
+ throw new TRPCError({
906
+ code: "NOT_FOUND",
907
+ message: "Announcement not found",
908
+ });
909
+ }
910
+ // Get reaction counts by type
911
+ const reactionCounts = await prisma.reaction.groupBy({
912
+ by: ['type'],
913
+ where: { announcementId: input.announcementId },
914
+ _count: { type: true },
915
+ });
916
+ // Get current user's reaction
917
+ const userReaction = await prisma.reaction.findUnique({
918
+ where: {
919
+ userId_announcementId: {
920
+ userId,
921
+ announcementId: input.announcementId,
922
+ },
923
+ },
924
+ });
925
+ // Format counts
926
+ const counts = {
927
+ THUMBSUP: 0,
928
+ CELEBRATE: 0,
929
+ CARE: 0,
930
+ HEART: 0,
931
+ IDEA: 0,
932
+ HAPPY: 0,
933
+ };
934
+ reactionCounts.forEach((item) => {
935
+ counts[item.type] = item._count.type;
936
+ });
937
+ return {
938
+ counts,
939
+ userReaction: userReaction?.type || null,
940
+ total: reactionCounts.reduce((sum, item) => sum + item._count.type, 0),
941
+ };
942
+ }
943
+ else if (input.commentId) {
944
+ // Verify comment exists
945
+ const comment = await prisma.announcementComment.findUnique({
946
+ where: { id: input.commentId },
947
+ include: {
948
+ announcement: {
949
+ select: {
950
+ classId: true,
951
+ },
952
+ },
953
+ },
954
+ });
955
+ if (!comment) {
956
+ throw new TRPCError({
957
+ code: "NOT_FOUND",
958
+ message: "Comment not found",
959
+ });
960
+ }
961
+ if (comment.announcement.classId !== input.classId) {
962
+ throw new TRPCError({
963
+ code: "FORBIDDEN",
964
+ message: "Comment does not belong to this class",
965
+ });
966
+ }
967
+ // Get reaction counts by type
968
+ const reactionCounts = await prisma.reaction.groupBy({
969
+ by: ['type'],
970
+ where: { commentId: input.commentId },
971
+ _count: { type: true },
972
+ });
973
+ // Get current user's reaction
974
+ const userReaction = await prisma.reaction.findUnique({
975
+ where: {
976
+ userId_commentId: {
977
+ userId,
978
+ commentId: input.commentId,
979
+ },
980
+ },
981
+ });
982
+ // Format counts
983
+ const counts = {
984
+ THUMBSUP: 0,
985
+ CELEBRATE: 0,
986
+ CARE: 0,
987
+ HEART: 0,
988
+ IDEA: 0,
989
+ HAPPY: 0,
990
+ };
991
+ reactionCounts.forEach((item) => {
992
+ counts[item.type] = item._count.type;
993
+ });
994
+ return {
995
+ counts,
996
+ userReaction: userReaction?.type || null,
997
+ total: reactionCounts.reduce((sum, item) => sum + item._count.type, 0),
998
+ };
999
+ }
1000
+ throw new TRPCError({
1001
+ code: "INTERNAL_SERVER_ERROR",
1002
+ message: "Unexpected error",
1003
+ });
1004
+ }),
119
1005
  });