@studious-lms/server 1.1.26 → 1.2.27

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.
@@ -4,6 +4,25 @@ import { prisma } from "../lib/prisma.js";
4
4
  import { TRPCError } from "@trpc/server";
5
5
  import { sendNotifications } from "../lib/notificationHandler.js";
6
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
+ });
7
26
  const AnnouncementSelect = {
8
27
  id: true,
9
28
  teacher: {
@@ -14,6 +33,18 @@ const AnnouncementSelect = {
14
33
  },
15
34
  remarks: true,
16
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
+ },
17
48
  };
18
49
  export const announcementRouter = createTRPCRouter({
19
50
  getAll: protectedClassMemberProcedure
@@ -25,23 +56,66 @@ export const announcementRouter = createTRPCRouter({
25
56
  where: {
26
57
  classId: input.classId,
27
58
  },
28
- select: AnnouncementSelect,
59
+ select: {
60
+ ...AnnouncementSelect,
61
+ _count: {
62
+ select: {
63
+ comments: true,
64
+ },
65
+ },
66
+ },
29
67
  orderBy: {
30
68
  createdAt: 'desc',
31
69
  },
32
70
  });
71
+ // Transform to include comment count
72
+ const announcementsWithCounts = announcements.map(announcement => ({
73
+ ...announcement,
74
+ commentCount: announcement._count.comments,
75
+ _count: undefined,
76
+ }));
33
77
  return {
34
- announcements,
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
+ }
100
+ return {
101
+ announcement,
35
102
  };
36
103
  }),
37
104
  create: protectedTeacherProcedure
38
105
  .input(z.object({
39
106
  classId: z.string(),
40
- 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(),
41
110
  }))
42
111
  .mutation(async ({ ctx, input }) => {
43
- const classId = input.classId;
44
- const remarks = input.remarks;
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
+ }
45
119
  const classData = await prisma.class.findUnique({
46
120
  where: { id: classId },
47
121
  include: {
@@ -61,7 +135,7 @@ export const announcementRouter = createTRPCRouter({
61
135
  remarks: remarks,
62
136
  teacher: {
63
137
  connect: {
64
- id: ctx.user?.id,
138
+ id: ctx.user.id,
65
139
  },
66
140
  },
67
141
  class: {
@@ -72,24 +146,62 @@ export const announcementRouter = createTRPCRouter({
72
146
  },
73
147
  select: AnnouncementSelect,
74
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
+ });
75
176
  sendNotifications(classData.students.map(student => student.id), {
76
177
  title: `🔔 Announcement for ${classData.name}`,
77
178
  content: remarks
78
179
  }).catch(error => {
79
- logger.error('Failed to send announcement notifications:');
180
+ logger.error('Failed to send announcement notifications:', error);
80
181
  });
81
182
  return {
82
- announcement,
183
+ announcement: announcementWithAttachments || announcement,
184
+ // Return upload URLs if files were provided
185
+ uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
83
186
  };
84
187
  }),
85
188
  update: protectedProcedure
86
189
  .input(z.object({
87
190
  id: z.string(),
88
191
  data: z.object({
89
- 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(),
90
196
  }),
91
197
  }))
92
198
  .mutation(async ({ ctx, input }) => {
199
+ if (!ctx.user) {
200
+ throw new TRPCError({
201
+ code: "UNAUTHORIZED",
202
+ message: "User must be authenticated",
203
+ });
204
+ }
93
205
  const announcement = await prisma.announcement.findUnique({
94
206
  where: { id: input.id },
95
207
  include: {
@@ -98,6 +210,21 @@ export const announcementRouter = createTRPCRouter({
98
210
  teachers: true,
99
211
  },
100
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
+ },
101
228
  },
102
229
  });
103
230
  if (!announcement) {
@@ -106,19 +233,88 @@ export const announcementRouter = createTRPCRouter({
106
233
  message: "Announcement not found",
107
234
  });
108
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
+ }
109
281
  const updatedAnnouncement = await prisma.announcement.update({
110
282
  where: { id: input.id },
111
283
  data: {
112
- 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
+ }),
113
298
  },
299
+ select: AnnouncementSelect,
114
300
  });
115
- 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
+ };
116
306
  }),
117
307
  delete: protectedProcedure
118
308
  .input(z.object({
119
309
  id: z.string(),
120
310
  }))
121
311
  .mutation(async ({ ctx, input }) => {
312
+ if (!ctx.user) {
313
+ throw new TRPCError({
314
+ code: "UNAUTHORIZED",
315
+ message: "User must be authenticated",
316
+ });
317
+ }
122
318
  const announcement = await prisma.announcement.findUnique({
123
319
  where: { id: input.id },
124
320
  include: {
@@ -135,9 +331,675 @@ export const announcementRouter = createTRPCRouter({
135
331
  message: "Announcement not found",
136
332
  });
137
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
+ }
138
344
  await prisma.announcement.delete({
139
345
  where: { id: input.id },
140
346
  });
141
347
  return { success: true };
142
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
+ }),
143
1005
  });