@studious-lms/server 1.1.8 → 1.1.10

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.
@@ -0,0 +1,596 @@
1
+ import { z } from 'zod';
2
+ import { createTRPCRouter, protectedProcedure } from '../trpc.js';
3
+ import { prisma } from '../lib/prisma.js';
4
+ import { pusher } from '../lib/pusher.js';
5
+ import { TRPCError } from '@trpc/server';
6
+
7
+ export const messageRouter = createTRPCRouter({
8
+ list: protectedProcedure
9
+ .input(
10
+ z.object({
11
+ conversationId: z.string(),
12
+ cursor: z.string().optional(),
13
+ limit: z.number().min(1).max(100).default(50),
14
+ })
15
+ )
16
+ .query(async ({ input, ctx }) => {
17
+ const userId = ctx.user!.id;
18
+ const { conversationId, cursor, limit } = input;
19
+
20
+ // Verify user is a member of the conversation
21
+ const membership = await prisma.conversationMember.findFirst({
22
+ where: {
23
+ conversationId,
24
+ userId,
25
+ },
26
+ });
27
+
28
+ if (!membership) {
29
+ throw new TRPCError({
30
+ code: 'FORBIDDEN',
31
+ message: 'Not a member of this conversation',
32
+ });
33
+ }
34
+
35
+ const messages = await prisma.message.findMany({
36
+ where: {
37
+ conversationId,
38
+ ...(cursor && {
39
+ createdAt: {
40
+ lt: new Date(cursor),
41
+ },
42
+ }),
43
+ },
44
+ include: {
45
+ sender: {
46
+ select: {
47
+ id: true,
48
+ username: true,
49
+ profile: {
50
+ select: {
51
+ displayName: true,
52
+ profilePicture: true,
53
+ },
54
+ },
55
+ },
56
+ },
57
+ mentions: {
58
+ include: {
59
+ user: {
60
+ select: {
61
+ id: true,
62
+ username: true,
63
+ profile: {
64
+ select: {
65
+ displayName: true,
66
+ },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ orderBy: {
74
+ createdAt: 'desc',
75
+ },
76
+ take: limit + 1,
77
+ });
78
+
79
+ let nextCursor: string | undefined = undefined;
80
+ if (messages.length > limit) {
81
+ const nextItem = messages.pop();
82
+ nextCursor = nextItem!.createdAt.toISOString();
83
+ }
84
+
85
+ return {
86
+ messages: messages.reverse().map((message) => ({
87
+ id: message.id,
88
+ content: message.content,
89
+ senderId: message.senderId,
90
+ conversationId: message.conversationId,
91
+ createdAt: message.createdAt,
92
+ sender: message.sender,
93
+ mentions: message.mentions.map((mention) => ({
94
+ user: mention.user,
95
+ })),
96
+ mentionsMe: message.mentions.some((mention) => mention.userId === userId),
97
+ })),
98
+ nextCursor,
99
+ };
100
+ }),
101
+
102
+ send: protectedProcedure
103
+ .input(
104
+ z.object({
105
+ conversationId: z.string(),
106
+ content: z.string().min(1).max(4000),
107
+ mentionedUserIds: z.array(z.string()).optional(),
108
+ })
109
+ )
110
+ .mutation(async ({ input, ctx }) => {
111
+ const userId = ctx.user!.id;
112
+ const { conversationId, content, mentionedUserIds = [] } = input;
113
+
114
+ // Verify user is a member of the conversation
115
+ const membership = await prisma.conversationMember.findFirst({
116
+ where: {
117
+ conversationId,
118
+ userId,
119
+ },
120
+ });
121
+
122
+ if (!membership) {
123
+ throw new TRPCError({
124
+ code: 'FORBIDDEN',
125
+ message: 'Not a member of this conversation',
126
+ });
127
+ }
128
+
129
+ // Verify mentioned users are members of the conversation
130
+ if (mentionedUserIds.length > 0) {
131
+ const mentionedMemberships = await prisma.conversationMember.findMany({
132
+ where: {
133
+ conversationId,
134
+ userId: { in: mentionedUserIds },
135
+ },
136
+ });
137
+
138
+ if (mentionedMemberships.length !== mentionedUserIds.length) {
139
+ throw new TRPCError({
140
+ code: 'BAD_REQUEST',
141
+ message: 'Some mentioned users are not members of this conversation',
142
+ });
143
+ }
144
+ }
145
+
146
+ // Create message, mentions, and update conversation timestamp
147
+ const result = await prisma.$transaction(async (tx) => {
148
+ const message = await tx.message.create({
149
+ data: {
150
+ content,
151
+ senderId: userId,
152
+ conversationId,
153
+ },
154
+ include: {
155
+ sender: {
156
+ select: {
157
+ id: true,
158
+ username: true,
159
+ profile: {
160
+ select: {
161
+ displayName: true,
162
+ profilePicture: true,
163
+ },
164
+ },
165
+ },
166
+ },
167
+ },
168
+ });
169
+
170
+ // Create mentions
171
+ if (mentionedUserIds.length > 0) {
172
+ await tx.mention.createMany({
173
+ data: mentionedUserIds.map((mentionedUserId) => ({
174
+ messageId: message.id,
175
+ userId: mentionedUserId,
176
+ })),
177
+ });
178
+ }
179
+
180
+ // Update conversation timestamp
181
+ await tx.conversation.update({
182
+ where: { id: conversationId },
183
+ data: { updatedAt: new Date() },
184
+ });
185
+
186
+ return message;
187
+ });
188
+
189
+ // Broadcast to Pusher channel
190
+ try {
191
+ await pusher.trigger(`conversation-${conversationId}`, 'new-message', {
192
+ id: result.id,
193
+ content: result.content,
194
+ senderId: result.senderId,
195
+ conversationId: result.conversationId,
196
+ createdAt: result.createdAt,
197
+ sender: result.sender,
198
+ mentionedUserIds,
199
+ });
200
+ } catch (error) {
201
+ console.error('Failed to broadcast message:', error);
202
+ // Don't fail the request if Pusher fails
203
+ }
204
+
205
+ return {
206
+ id: result.id,
207
+ content: result.content,
208
+ senderId: result.senderId,
209
+ conversationId: result.conversationId,
210
+ createdAt: result.createdAt,
211
+ sender: result.sender,
212
+ mentionedUserIds,
213
+ };
214
+ }),
215
+ update: protectedProcedure
216
+ .input(
217
+ z.object({
218
+ messageId: z.string(),
219
+ content: z.string().min(1).max(4000),
220
+ mentionedUserIds: z.array(z.string()).optional(),
221
+ })
222
+ )
223
+ .mutation(async ({ input, ctx }) => {
224
+ const userId = ctx.user!.id;
225
+ const { messageId, content, mentionedUserIds = [] } = input;
226
+
227
+ // Get the existing message and verify user is the sender
228
+ const existingMessage = await prisma.message.findUnique({
229
+ where: { id: messageId },
230
+ include: {
231
+ sender: {
232
+ select: {
233
+ id: true,
234
+ username: true,
235
+ profile: {
236
+ select: {
237
+ displayName: true,
238
+ profilePicture: true,
239
+ },
240
+ },
241
+ },
242
+ },
243
+ },
244
+ });
245
+
246
+ if (!existingMessage) {
247
+ throw new TRPCError({
248
+ code: 'NOT_FOUND',
249
+ message: 'Message not found',
250
+ });
251
+ }
252
+
253
+ if (existingMessage.senderId !== userId) {
254
+ throw new TRPCError({
255
+ code: 'FORBIDDEN',
256
+ message: 'Not the sender of this message',
257
+ });
258
+ }
259
+
260
+ // Verify user is still a member of the conversation
261
+ const membership = await prisma.conversationMember.findFirst({
262
+ where: {
263
+ conversationId: existingMessage.conversationId,
264
+ userId,
265
+ },
266
+ });
267
+
268
+ if (!membership) {
269
+ throw new TRPCError({
270
+ code: 'FORBIDDEN',
271
+ message: 'Not a member of this conversation',
272
+ });
273
+ }
274
+
275
+ // Verify mentioned users are members of the conversation
276
+ if (mentionedUserIds.length > 0) {
277
+ const mentionedMemberships = await prisma.conversationMember.findMany({
278
+ where: {
279
+ conversationId: existingMessage.conversationId,
280
+ userId: { in: mentionedUserIds },
281
+ },
282
+ });
283
+
284
+ if (mentionedMemberships.length !== mentionedUserIds.length) {
285
+ throw new TRPCError({
286
+ code: 'BAD_REQUEST',
287
+ message: 'Some mentioned users are not members of this conversation',
288
+ });
289
+ }
290
+ }
291
+
292
+ // Update message and mentions in transaction
293
+ const updatedMessage = await prisma.$transaction(async (tx) => {
294
+ // Update the message content
295
+ const message = await tx.message.update({
296
+ where: { id: messageId },
297
+ data: { content },
298
+ include: {
299
+ sender: {
300
+ select: {
301
+ id: true,
302
+ username: true,
303
+ profile: {
304
+ select: {
305
+ displayName: true,
306
+ profilePicture: true,
307
+ },
308
+ },
309
+ },
310
+ },
311
+ },
312
+ });
313
+
314
+ // Delete existing mentions
315
+ await tx.mention.deleteMany({
316
+ where: { messageId },
317
+ });
318
+
319
+ // Create new mentions if any
320
+ if (mentionedUserIds.length > 0) {
321
+ await tx.mention.createMany({
322
+ data: mentionedUserIds.map((mentionedUserId) => ({
323
+ messageId,
324
+ userId: mentionedUserId,
325
+ })),
326
+ });
327
+ }
328
+
329
+ return message;
330
+ });
331
+
332
+ // Broadcast message update to Pusher
333
+ try {
334
+ await pusher.trigger(`conversation-${existingMessage.conversationId}`, 'message-updated', {
335
+ id: updatedMessage.id,
336
+ content: updatedMessage.content,
337
+ senderId: updatedMessage.senderId,
338
+ conversationId: updatedMessage.conversationId,
339
+ createdAt: updatedMessage.createdAt,
340
+ sender: updatedMessage.sender,
341
+ mentionedUserIds,
342
+ });
343
+ } catch (error) {
344
+ console.error('Failed to broadcast message update:', error);
345
+ // Don't fail the request if Pusher fails
346
+ }
347
+
348
+ return {
349
+ id: updatedMessage.id,
350
+ content: updatedMessage.content,
351
+ senderId: updatedMessage.senderId,
352
+ conversationId: updatedMessage.conversationId,
353
+ createdAt: updatedMessage.createdAt,
354
+ sender: updatedMessage.sender,
355
+ mentionedUserIds,
356
+ };
357
+ }),
358
+
359
+ delete: protectedProcedure
360
+ .input(
361
+ z.object({
362
+ messageId: z.string(),
363
+ })
364
+ )
365
+ .mutation(async ({ input, ctx }) => {
366
+ const userId = ctx.user!.id;
367
+ const { messageId } = input;
368
+
369
+ // Get the message and verify user is the sender
370
+ const existingMessage = await prisma.message.findUnique({
371
+ where: { id: messageId },
372
+ include: {
373
+ sender: {
374
+ select: {
375
+ id: true,
376
+ username: true,
377
+ },
378
+ },
379
+ },
380
+ });
381
+
382
+ if (!existingMessage) {
383
+ throw new TRPCError({
384
+ code: 'NOT_FOUND',
385
+ message: 'Message not found',
386
+ });
387
+ }
388
+
389
+ if (existingMessage.senderId !== userId) {
390
+ throw new TRPCError({
391
+ code: 'FORBIDDEN',
392
+ message: 'Not the sender of this message',
393
+ });
394
+ }
395
+
396
+ // Verify user is still a member of the conversation
397
+ const membership = await prisma.conversationMember.findFirst({
398
+ where: {
399
+ conversationId: existingMessage.conversationId,
400
+ userId,
401
+ },
402
+ });
403
+
404
+ if (!membership) {
405
+ throw new TRPCError({
406
+ code: 'FORBIDDEN',
407
+ message: 'Not a member of this conversation',
408
+ });
409
+ }
410
+
411
+ // Delete message and all related mentions in transaction
412
+ await prisma.$transaction(async (tx) => {
413
+ // Delete mentions first (due to foreign key constraint)
414
+ await tx.mention.deleteMany({
415
+ where: { messageId },
416
+ });
417
+
418
+ // Delete the message
419
+ await tx.message.delete({
420
+ where: { id: messageId },
421
+ });
422
+ });
423
+
424
+ // Broadcast message deletion to Pusher
425
+ try {
426
+ await pusher.trigger(`conversation-${existingMessage.conversationId}`, 'message-deleted', {
427
+ messageId,
428
+ conversationId: existingMessage.conversationId,
429
+ senderId: existingMessage.senderId,
430
+ });
431
+ } catch (error) {
432
+ console.error('Failed to broadcast message deletion:', error);
433
+ // Don't fail the request if Pusher fails
434
+ }
435
+
436
+ return {
437
+ success: true,
438
+ messageId,
439
+ };
440
+ }),
441
+ markAsRead: protectedProcedure
442
+ .input(
443
+ z.object({
444
+ conversationId: z.string(),
445
+ })
446
+ )
447
+ .mutation(async ({ input, ctx }) => {
448
+ const userId = ctx.user!.id;
449
+ const { conversationId } = input;
450
+
451
+ // Verify user is a member of the conversation and update lastViewedAt
452
+ const membership = await prisma.conversationMember.findFirst({
453
+ where: {
454
+ conversationId,
455
+ userId,
456
+ },
457
+ });
458
+
459
+ if (!membership) {
460
+ throw new TRPCError({
461
+ code: 'FORBIDDEN',
462
+ message: 'Not a member of this conversation',
463
+ });
464
+ }
465
+
466
+ // Update the user's lastViewedAt timestamp for this conversation
467
+ await prisma.conversationMember.update({
468
+ where: {
469
+ id: membership.id,
470
+ },
471
+ data: {
472
+ lastViewedAt: new Date(),
473
+ },
474
+ });
475
+
476
+ // Broadcast that user has viewed the conversation
477
+ try {
478
+ await pusher.trigger(`conversation-${conversationId}`, 'conversation-viewed', {
479
+ userId,
480
+ viewedAt: new Date(),
481
+ });
482
+ } catch (error) {
483
+ console.error('Failed to broadcast conversation view:', error);
484
+ // Don't fail the request if Pusher fails
485
+ }
486
+
487
+ return { success: true };
488
+ }),
489
+
490
+ markMentionsAsRead: protectedProcedure
491
+ .input(
492
+ z.object({
493
+ conversationId: z.string(),
494
+ })
495
+ )
496
+ .mutation(async ({ input, ctx }) => {
497
+ const userId = ctx.user!.id;
498
+ const { conversationId } = input;
499
+
500
+ // Verify user is a member of the conversation and update lastViewedMentionAt
501
+ const membership = await prisma.conversationMember.findFirst({
502
+ where: {
503
+ conversationId,
504
+ userId,
505
+ },
506
+ });
507
+
508
+ if (!membership) {
509
+ throw new TRPCError({
510
+ code: 'FORBIDDEN',
511
+ message: 'Not a member of this conversation',
512
+ });
513
+ }
514
+
515
+ // Update the user's lastViewedMentionAt timestamp for this conversation
516
+ await prisma.conversationMember.update({
517
+ where: {
518
+ id: membership.id,
519
+ },
520
+ data: {
521
+ lastViewedMentionAt: new Date(),
522
+ },
523
+ });
524
+
525
+ // Broadcast that user has viewed mentions
526
+ try {
527
+ await pusher.trigger(`conversation-${conversationId}`, 'mentions-viewed', {
528
+ userId,
529
+ viewedAt: new Date(),
530
+ });
531
+ } catch (error) {
532
+ console.error('Failed to broadcast mentions view:', error);
533
+ // Don't fail the request if Pusher fails
534
+ }
535
+
536
+ return { success: true };
537
+ }),
538
+
539
+ getUnreadCount: protectedProcedure
540
+ .input(z.object({ conversationId: z.string() }))
541
+ .query(async ({ input, ctx }) => {
542
+ const userId = ctx.user!.id;
543
+ const { conversationId } = input;
544
+
545
+ // Get user's membership with lastViewedAt and lastViewedMentionAt
546
+ const membership = await prisma.conversationMember.findFirst({
547
+ where: {
548
+ conversationId,
549
+ userId,
550
+ },
551
+ });
552
+
553
+ if (!membership) {
554
+ throw new TRPCError({
555
+ code: 'FORBIDDEN',
556
+ message: 'Not a member of this conversation',
557
+ });
558
+ }
559
+
560
+ // Count regular unread messages
561
+ const unreadCount = await prisma.message.count({
562
+ where: {
563
+ conversationId,
564
+ senderId: { not: userId },
565
+ ...(membership.lastViewedAt && {
566
+ createdAt: { gt: membership.lastViewedAt }
567
+ }),
568
+ },
569
+ });
570
+
571
+ // Count unread mentions
572
+ // Use the later of lastViewedAt or lastViewedMentionAt
573
+ // This means if user viewed conversation after mention, mention is considered read
574
+ const mentionCutoffTime = membership.lastViewedMentionAt && membership.lastViewedAt
575
+ ? (membership.lastViewedMentionAt > membership.lastViewedAt ? membership.lastViewedMentionAt : membership.lastViewedAt)
576
+ : (membership.lastViewedMentionAt || membership.lastViewedAt);
577
+
578
+ const unreadMentionCount = await prisma.mention.count({
579
+ where: {
580
+ userId,
581
+ message: {
582
+ conversationId,
583
+ senderId: { not: userId },
584
+ ...(mentionCutoffTime && {
585
+ createdAt: { gt: mentionCutoffTime }
586
+ }),
587
+ },
588
+ },
589
+ });
590
+
591
+ return {
592
+ unreadCount,
593
+ unreadMentionCount
594
+ };
595
+ }),
596
+ });
@@ -228,14 +228,16 @@ export const userRouter = createTRPCRouter({
228
228
  const uniqueFilename = `${ctx.user.id}-${Date.now()}.${fileExtension}`;
229
229
  const filePath = `users/${ctx.user.id}/profile/${uniqueFilename}`;
230
230
 
231
- // Generate signed URL for direct upload (write permission)
232
- const uploadUrl = await getSignedUrl(filePath, 'write', input.fileType);
231
+ // Generate backend proxy upload URL instead of direct GCS signed URL
232
+ const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001';
233
+ const uploadUrl = `${backendUrl}/api/upload/${encodeURIComponent(filePath)}`;
233
234
 
234
235
  logger.info('Generated upload URL', {
235
236
  userId: ctx.user.id,
236
237
  filePath,
237
238
  fileName: uniqueFilename,
238
- fileType: input.fileType
239
+ fileType: input.fileType,
240
+ uploadUrl
239
241
  });
240
242
 
241
243
  return {