@studious-lms/server 1.1.9 → 1.1.11

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.
@@ -212,7 +212,232 @@ export const messageRouter = createTRPCRouter({
212
212
  mentionedUserIds,
213
213
  };
214
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
+ });
215
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
+ }),
216
441
  markAsRead: protectedProcedure
217
442
  .input(
218
443
  z.object({
@@ -344,14 +569,20 @@ export const messageRouter = createTRPCRouter({
344
569
  });
345
570
 
346
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
+
347
578
  const unreadMentionCount = await prisma.mention.count({
348
579
  where: {
349
580
  userId,
350
581
  message: {
351
582
  conversationId,
352
583
  senderId: { not: userId },
353
- ...(membership.lastViewedMentionAt && {
354
- createdAt: { gt: membership.lastViewedMentionAt }
584
+ ...(mentionCutoffTime && {
585
+ createdAt: { gt: mentionCutoffTime }
355
586
  }),
356
587
  },
357
588
  },
@@ -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 {