@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.
- package/CHAT_API_SPEC.md +579 -0
- package/dist/index.js +68 -0
- package/dist/lib/pusher.d.ts +4 -0
- package/dist/lib/pusher.d.ts.map +1 -0
- package/dist/lib/pusher.js +9 -0
- package/dist/routers/_app.d.ts +548 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +1 -0
- package/dist/routers/conversation.d.ts +134 -0
- package/dist/routers/conversation.d.ts.map +1 -0
- package/dist/routers/conversation.js +267 -0
- package/dist/routers/message.d.ts +142 -0
- package/dist/routers/message.d.ts.map +1 -0
- package/dist/routers/message.js +531 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +5 -4
- package/package.json +2 -1
- package/prisma/migrations/20250922165147_add_chat_models/migration.sql +68 -0
- package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +5 -0
- package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +20 -0
- package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +21 -0
- package/prisma/schema.prisma +68 -1
- package/src/index.ts +79 -0
- package/src/lib/pusher.ts +11 -0
- package/src/routers/_app.ts +4 -0
- package/src/routers/auth.ts +1 -0
- package/src/routers/class.ts +1 -0
- package/src/routers/conversation.ts +292 -0
- package/src/routers/message.ts +596 -0
- package/src/routers/user.ts +5 -3
|
@@ -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
|
+
});
|
package/src/routers/user.ts
CHANGED
|
@@ -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
|
|
232
|
-
const
|
|
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 {
|