@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,531 @@
|
|
|
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
|
+
export const messageRouter = createTRPCRouter({
|
|
7
|
+
list: protectedProcedure
|
|
8
|
+
.input(z.object({
|
|
9
|
+
conversationId: z.string(),
|
|
10
|
+
cursor: z.string().optional(),
|
|
11
|
+
limit: z.number().min(1).max(100).default(50),
|
|
12
|
+
}))
|
|
13
|
+
.query(async ({ input, ctx }) => {
|
|
14
|
+
const userId = ctx.user.id;
|
|
15
|
+
const { conversationId, cursor, limit } = input;
|
|
16
|
+
// Verify user is a member of the conversation
|
|
17
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
18
|
+
where: {
|
|
19
|
+
conversationId,
|
|
20
|
+
userId,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!membership) {
|
|
24
|
+
throw new TRPCError({
|
|
25
|
+
code: 'FORBIDDEN',
|
|
26
|
+
message: 'Not a member of this conversation',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const messages = await prisma.message.findMany({
|
|
30
|
+
where: {
|
|
31
|
+
conversationId,
|
|
32
|
+
...(cursor && {
|
|
33
|
+
createdAt: {
|
|
34
|
+
lt: new Date(cursor),
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
include: {
|
|
39
|
+
sender: {
|
|
40
|
+
select: {
|
|
41
|
+
id: true,
|
|
42
|
+
username: true,
|
|
43
|
+
profile: {
|
|
44
|
+
select: {
|
|
45
|
+
displayName: true,
|
|
46
|
+
profilePicture: true,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
mentions: {
|
|
52
|
+
include: {
|
|
53
|
+
user: {
|
|
54
|
+
select: {
|
|
55
|
+
id: true,
|
|
56
|
+
username: true,
|
|
57
|
+
profile: {
|
|
58
|
+
select: {
|
|
59
|
+
displayName: true,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
orderBy: {
|
|
68
|
+
createdAt: 'desc',
|
|
69
|
+
},
|
|
70
|
+
take: limit + 1,
|
|
71
|
+
});
|
|
72
|
+
let nextCursor = undefined;
|
|
73
|
+
if (messages.length > limit) {
|
|
74
|
+
const nextItem = messages.pop();
|
|
75
|
+
nextCursor = nextItem.createdAt.toISOString();
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
messages: messages.reverse().map((message) => ({
|
|
79
|
+
id: message.id,
|
|
80
|
+
content: message.content,
|
|
81
|
+
senderId: message.senderId,
|
|
82
|
+
conversationId: message.conversationId,
|
|
83
|
+
createdAt: message.createdAt,
|
|
84
|
+
sender: message.sender,
|
|
85
|
+
mentions: message.mentions.map((mention) => ({
|
|
86
|
+
user: mention.user,
|
|
87
|
+
})),
|
|
88
|
+
mentionsMe: message.mentions.some((mention) => mention.userId === userId),
|
|
89
|
+
})),
|
|
90
|
+
nextCursor,
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
send: protectedProcedure
|
|
94
|
+
.input(z.object({
|
|
95
|
+
conversationId: z.string(),
|
|
96
|
+
content: z.string().min(1).max(4000),
|
|
97
|
+
mentionedUserIds: z.array(z.string()).optional(),
|
|
98
|
+
}))
|
|
99
|
+
.mutation(async ({ input, ctx }) => {
|
|
100
|
+
const userId = ctx.user.id;
|
|
101
|
+
const { conversationId, content, mentionedUserIds = [] } = input;
|
|
102
|
+
// Verify user is a member of the conversation
|
|
103
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
104
|
+
where: {
|
|
105
|
+
conversationId,
|
|
106
|
+
userId,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
if (!membership) {
|
|
110
|
+
throw new TRPCError({
|
|
111
|
+
code: 'FORBIDDEN',
|
|
112
|
+
message: 'Not a member of this conversation',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Verify mentioned users are members of the conversation
|
|
116
|
+
if (mentionedUserIds.length > 0) {
|
|
117
|
+
const mentionedMemberships = await prisma.conversationMember.findMany({
|
|
118
|
+
where: {
|
|
119
|
+
conversationId,
|
|
120
|
+
userId: { in: mentionedUserIds },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (mentionedMemberships.length !== mentionedUserIds.length) {
|
|
124
|
+
throw new TRPCError({
|
|
125
|
+
code: 'BAD_REQUEST',
|
|
126
|
+
message: 'Some mentioned users are not members of this conversation',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Create message, mentions, and update conversation timestamp
|
|
131
|
+
const result = await prisma.$transaction(async (tx) => {
|
|
132
|
+
const message = await tx.message.create({
|
|
133
|
+
data: {
|
|
134
|
+
content,
|
|
135
|
+
senderId: userId,
|
|
136
|
+
conversationId,
|
|
137
|
+
},
|
|
138
|
+
include: {
|
|
139
|
+
sender: {
|
|
140
|
+
select: {
|
|
141
|
+
id: true,
|
|
142
|
+
username: true,
|
|
143
|
+
profile: {
|
|
144
|
+
select: {
|
|
145
|
+
displayName: true,
|
|
146
|
+
profilePicture: true,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
// Create mentions
|
|
154
|
+
if (mentionedUserIds.length > 0) {
|
|
155
|
+
await tx.mention.createMany({
|
|
156
|
+
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
157
|
+
messageId: message.id,
|
|
158
|
+
userId: mentionedUserId,
|
|
159
|
+
})),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Update conversation timestamp
|
|
163
|
+
await tx.conversation.update({
|
|
164
|
+
where: { id: conversationId },
|
|
165
|
+
data: { updatedAt: new Date() },
|
|
166
|
+
});
|
|
167
|
+
return message;
|
|
168
|
+
});
|
|
169
|
+
// Broadcast to Pusher channel
|
|
170
|
+
try {
|
|
171
|
+
await pusher.trigger(`conversation-${conversationId}`, 'new-message', {
|
|
172
|
+
id: result.id,
|
|
173
|
+
content: result.content,
|
|
174
|
+
senderId: result.senderId,
|
|
175
|
+
conversationId: result.conversationId,
|
|
176
|
+
createdAt: result.createdAt,
|
|
177
|
+
sender: result.sender,
|
|
178
|
+
mentionedUserIds,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
console.error('Failed to broadcast message:', error);
|
|
183
|
+
// Don't fail the request if Pusher fails
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
id: result.id,
|
|
187
|
+
content: result.content,
|
|
188
|
+
senderId: result.senderId,
|
|
189
|
+
conversationId: result.conversationId,
|
|
190
|
+
createdAt: result.createdAt,
|
|
191
|
+
sender: result.sender,
|
|
192
|
+
mentionedUserIds,
|
|
193
|
+
};
|
|
194
|
+
}),
|
|
195
|
+
update: protectedProcedure
|
|
196
|
+
.input(z.object({
|
|
197
|
+
messageId: z.string(),
|
|
198
|
+
content: z.string().min(1).max(4000),
|
|
199
|
+
mentionedUserIds: z.array(z.string()).optional(),
|
|
200
|
+
}))
|
|
201
|
+
.mutation(async ({ input, ctx }) => {
|
|
202
|
+
const userId = ctx.user.id;
|
|
203
|
+
const { messageId, content, mentionedUserIds = [] } = input;
|
|
204
|
+
// Get the existing message and verify user is the sender
|
|
205
|
+
const existingMessage = await prisma.message.findUnique({
|
|
206
|
+
where: { id: messageId },
|
|
207
|
+
include: {
|
|
208
|
+
sender: {
|
|
209
|
+
select: {
|
|
210
|
+
id: true,
|
|
211
|
+
username: true,
|
|
212
|
+
profile: {
|
|
213
|
+
select: {
|
|
214
|
+
displayName: true,
|
|
215
|
+
profilePicture: true,
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
if (!existingMessage) {
|
|
223
|
+
throw new TRPCError({
|
|
224
|
+
code: 'NOT_FOUND',
|
|
225
|
+
message: 'Message not found',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (existingMessage.senderId !== userId) {
|
|
229
|
+
throw new TRPCError({
|
|
230
|
+
code: 'FORBIDDEN',
|
|
231
|
+
message: 'Not the sender of this message',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// Verify user is still a member of the conversation
|
|
235
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
236
|
+
where: {
|
|
237
|
+
conversationId: existingMessage.conversationId,
|
|
238
|
+
userId,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
if (!membership) {
|
|
242
|
+
throw new TRPCError({
|
|
243
|
+
code: 'FORBIDDEN',
|
|
244
|
+
message: 'Not a member of this conversation',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Verify mentioned users are members of the conversation
|
|
248
|
+
if (mentionedUserIds.length > 0) {
|
|
249
|
+
const mentionedMemberships = await prisma.conversationMember.findMany({
|
|
250
|
+
where: {
|
|
251
|
+
conversationId: existingMessage.conversationId,
|
|
252
|
+
userId: { in: mentionedUserIds },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
if (mentionedMemberships.length !== mentionedUserIds.length) {
|
|
256
|
+
throw new TRPCError({
|
|
257
|
+
code: 'BAD_REQUEST',
|
|
258
|
+
message: 'Some mentioned users are not members of this conversation',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Update message and mentions in transaction
|
|
263
|
+
const updatedMessage = await prisma.$transaction(async (tx) => {
|
|
264
|
+
// Update the message content
|
|
265
|
+
const message = await tx.message.update({
|
|
266
|
+
where: { id: messageId },
|
|
267
|
+
data: { content },
|
|
268
|
+
include: {
|
|
269
|
+
sender: {
|
|
270
|
+
select: {
|
|
271
|
+
id: true,
|
|
272
|
+
username: true,
|
|
273
|
+
profile: {
|
|
274
|
+
select: {
|
|
275
|
+
displayName: true,
|
|
276
|
+
profilePicture: true,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
// Delete existing mentions
|
|
284
|
+
await tx.mention.deleteMany({
|
|
285
|
+
where: { messageId },
|
|
286
|
+
});
|
|
287
|
+
// Create new mentions if any
|
|
288
|
+
if (mentionedUserIds.length > 0) {
|
|
289
|
+
await tx.mention.createMany({
|
|
290
|
+
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
291
|
+
messageId,
|
|
292
|
+
userId: mentionedUserId,
|
|
293
|
+
})),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return message;
|
|
297
|
+
});
|
|
298
|
+
// Broadcast message update to Pusher
|
|
299
|
+
try {
|
|
300
|
+
await pusher.trigger(`conversation-${existingMessage.conversationId}`, 'message-updated', {
|
|
301
|
+
id: updatedMessage.id,
|
|
302
|
+
content: updatedMessage.content,
|
|
303
|
+
senderId: updatedMessage.senderId,
|
|
304
|
+
conversationId: updatedMessage.conversationId,
|
|
305
|
+
createdAt: updatedMessage.createdAt,
|
|
306
|
+
sender: updatedMessage.sender,
|
|
307
|
+
mentionedUserIds,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
console.error('Failed to broadcast message update:', error);
|
|
312
|
+
// Don't fail the request if Pusher fails
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
id: updatedMessage.id,
|
|
316
|
+
content: updatedMessage.content,
|
|
317
|
+
senderId: updatedMessage.senderId,
|
|
318
|
+
conversationId: updatedMessage.conversationId,
|
|
319
|
+
createdAt: updatedMessage.createdAt,
|
|
320
|
+
sender: updatedMessage.sender,
|
|
321
|
+
mentionedUserIds,
|
|
322
|
+
};
|
|
323
|
+
}),
|
|
324
|
+
delete: protectedProcedure
|
|
325
|
+
.input(z.object({
|
|
326
|
+
messageId: z.string(),
|
|
327
|
+
}))
|
|
328
|
+
.mutation(async ({ input, ctx }) => {
|
|
329
|
+
const userId = ctx.user.id;
|
|
330
|
+
const { messageId } = input;
|
|
331
|
+
// Get the message and verify user is the sender
|
|
332
|
+
const existingMessage = await prisma.message.findUnique({
|
|
333
|
+
where: { id: messageId },
|
|
334
|
+
include: {
|
|
335
|
+
sender: {
|
|
336
|
+
select: {
|
|
337
|
+
id: true,
|
|
338
|
+
username: true,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
if (!existingMessage) {
|
|
344
|
+
throw new TRPCError({
|
|
345
|
+
code: 'NOT_FOUND',
|
|
346
|
+
message: 'Message not found',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (existingMessage.senderId !== userId) {
|
|
350
|
+
throw new TRPCError({
|
|
351
|
+
code: 'FORBIDDEN',
|
|
352
|
+
message: 'Not the sender of this message',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Verify user is still a member of the conversation
|
|
356
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
357
|
+
where: {
|
|
358
|
+
conversationId: existingMessage.conversationId,
|
|
359
|
+
userId,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
if (!membership) {
|
|
363
|
+
throw new TRPCError({
|
|
364
|
+
code: 'FORBIDDEN',
|
|
365
|
+
message: 'Not a member of this conversation',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Delete message and all related mentions in transaction
|
|
369
|
+
await prisma.$transaction(async (tx) => {
|
|
370
|
+
// Delete mentions first (due to foreign key constraint)
|
|
371
|
+
await tx.mention.deleteMany({
|
|
372
|
+
where: { messageId },
|
|
373
|
+
});
|
|
374
|
+
// Delete the message
|
|
375
|
+
await tx.message.delete({
|
|
376
|
+
where: { id: messageId },
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
// Broadcast message deletion to Pusher
|
|
380
|
+
try {
|
|
381
|
+
await pusher.trigger(`conversation-${existingMessage.conversationId}`, 'message-deleted', {
|
|
382
|
+
messageId,
|
|
383
|
+
conversationId: existingMessage.conversationId,
|
|
384
|
+
senderId: existingMessage.senderId,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
console.error('Failed to broadcast message deletion:', error);
|
|
389
|
+
// Don't fail the request if Pusher fails
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
messageId,
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
396
|
+
markAsRead: protectedProcedure
|
|
397
|
+
.input(z.object({
|
|
398
|
+
conversationId: z.string(),
|
|
399
|
+
}))
|
|
400
|
+
.mutation(async ({ input, ctx }) => {
|
|
401
|
+
const userId = ctx.user.id;
|
|
402
|
+
const { conversationId } = input;
|
|
403
|
+
// Verify user is a member of the conversation and update lastViewedAt
|
|
404
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
405
|
+
where: {
|
|
406
|
+
conversationId,
|
|
407
|
+
userId,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
if (!membership) {
|
|
411
|
+
throw new TRPCError({
|
|
412
|
+
code: 'FORBIDDEN',
|
|
413
|
+
message: 'Not a member of this conversation',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// Update the user's lastViewedAt timestamp for this conversation
|
|
417
|
+
await prisma.conversationMember.update({
|
|
418
|
+
where: {
|
|
419
|
+
id: membership.id,
|
|
420
|
+
},
|
|
421
|
+
data: {
|
|
422
|
+
lastViewedAt: new Date(),
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
// Broadcast that user has viewed the conversation
|
|
426
|
+
try {
|
|
427
|
+
await pusher.trigger(`conversation-${conversationId}`, 'conversation-viewed', {
|
|
428
|
+
userId,
|
|
429
|
+
viewedAt: new Date(),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
console.error('Failed to broadcast conversation view:', error);
|
|
434
|
+
// Don't fail the request if Pusher fails
|
|
435
|
+
}
|
|
436
|
+
return { success: true };
|
|
437
|
+
}),
|
|
438
|
+
markMentionsAsRead: protectedProcedure
|
|
439
|
+
.input(z.object({
|
|
440
|
+
conversationId: z.string(),
|
|
441
|
+
}))
|
|
442
|
+
.mutation(async ({ input, ctx }) => {
|
|
443
|
+
const userId = ctx.user.id;
|
|
444
|
+
const { conversationId } = input;
|
|
445
|
+
// Verify user is a member of the conversation and update lastViewedMentionAt
|
|
446
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
447
|
+
where: {
|
|
448
|
+
conversationId,
|
|
449
|
+
userId,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
if (!membership) {
|
|
453
|
+
throw new TRPCError({
|
|
454
|
+
code: 'FORBIDDEN',
|
|
455
|
+
message: 'Not a member of this conversation',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// Update the user's lastViewedMentionAt timestamp for this conversation
|
|
459
|
+
await prisma.conversationMember.update({
|
|
460
|
+
where: {
|
|
461
|
+
id: membership.id,
|
|
462
|
+
},
|
|
463
|
+
data: {
|
|
464
|
+
lastViewedMentionAt: new Date(),
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
// Broadcast that user has viewed mentions
|
|
468
|
+
try {
|
|
469
|
+
await pusher.trigger(`conversation-${conversationId}`, 'mentions-viewed', {
|
|
470
|
+
userId,
|
|
471
|
+
viewedAt: new Date(),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
console.error('Failed to broadcast mentions view:', error);
|
|
476
|
+
// Don't fail the request if Pusher fails
|
|
477
|
+
}
|
|
478
|
+
return { success: true };
|
|
479
|
+
}),
|
|
480
|
+
getUnreadCount: protectedProcedure
|
|
481
|
+
.input(z.object({ conversationId: z.string() }))
|
|
482
|
+
.query(async ({ input, ctx }) => {
|
|
483
|
+
const userId = ctx.user.id;
|
|
484
|
+
const { conversationId } = input;
|
|
485
|
+
// Get user's membership with lastViewedAt and lastViewedMentionAt
|
|
486
|
+
const membership = await prisma.conversationMember.findFirst({
|
|
487
|
+
where: {
|
|
488
|
+
conversationId,
|
|
489
|
+
userId,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
if (!membership) {
|
|
493
|
+
throw new TRPCError({
|
|
494
|
+
code: 'FORBIDDEN',
|
|
495
|
+
message: 'Not a member of this conversation',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
// Count regular unread messages
|
|
499
|
+
const unreadCount = await prisma.message.count({
|
|
500
|
+
where: {
|
|
501
|
+
conversationId,
|
|
502
|
+
senderId: { not: userId },
|
|
503
|
+
...(membership.lastViewedAt && {
|
|
504
|
+
createdAt: { gt: membership.lastViewedAt }
|
|
505
|
+
}),
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
// Count unread mentions
|
|
509
|
+
// Use the later of lastViewedAt or lastViewedMentionAt
|
|
510
|
+
// This means if user viewed conversation after mention, mention is considered read
|
|
511
|
+
const mentionCutoffTime = membership.lastViewedMentionAt && membership.lastViewedAt
|
|
512
|
+
? (membership.lastViewedMentionAt > membership.lastViewedAt ? membership.lastViewedMentionAt : membership.lastViewedAt)
|
|
513
|
+
: (membership.lastViewedMentionAt || membership.lastViewedAt);
|
|
514
|
+
const unreadMentionCount = await prisma.mention.count({
|
|
515
|
+
where: {
|
|
516
|
+
userId,
|
|
517
|
+
message: {
|
|
518
|
+
conversationId,
|
|
519
|
+
senderId: { not: userId },
|
|
520
|
+
...(mentionCutoffTime && {
|
|
521
|
+
createdAt: { gt: mentionCutoffTime }
|
|
522
|
+
}),
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
return {
|
|
527
|
+
unreadCount,
|
|
528
|
+
unreadMentionCount
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/routers/user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2DxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/routers/user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2DxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuMrB,CAAC"}
|
package/dist/routers/user.js
CHANGED
|
@@ -2,7 +2,6 @@ import { z } from "zod";
|
|
|
2
2
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
3
3
|
import { TRPCError } from "@trpc/server";
|
|
4
4
|
import { prisma } from "../lib/prisma.js";
|
|
5
|
-
import { getSignedUrl } from "../lib/googleCloudStorage.js";
|
|
6
5
|
import { logger } from "../utils/logger.js";
|
|
7
6
|
// Helper function to convert file path to backend proxy URL
|
|
8
7
|
function getFileUrl(filePath) {
|
|
@@ -203,13 +202,15 @@ export const userRouter = createTRPCRouter({
|
|
|
203
202
|
const fileExtension = input.fileName.split('.').pop();
|
|
204
203
|
const uniqueFilename = `${ctx.user.id}-${Date.now()}.${fileExtension}`;
|
|
205
204
|
const filePath = `users/${ctx.user.id}/profile/${uniqueFilename}`;
|
|
206
|
-
// Generate
|
|
207
|
-
const
|
|
205
|
+
// Generate backend proxy upload URL instead of direct GCS signed URL
|
|
206
|
+
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001';
|
|
207
|
+
const uploadUrl = `${backendUrl}/api/upload/${encodeURIComponent(filePath)}`;
|
|
208
208
|
logger.info('Generated upload URL', {
|
|
209
209
|
userId: ctx.user.id,
|
|
210
210
|
filePath,
|
|
211
211
|
fileName: uniqueFilename,
|
|
212
|
-
fileType: input.fileType
|
|
212
|
+
fileType: input.fileType,
|
|
213
|
+
uploadUrl
|
|
213
214
|
});
|
|
214
215
|
return {
|
|
215
216
|
uploadUrl,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studious-lms/server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.10",
|
|
4
4
|
"description": "Backend server for Studious application",
|
|
5
5
|
"main": "dist/exportType.js",
|
|
6
6
|
"types": "dist/exportType.d.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"express": "^4.18.3",
|
|
32
32
|
"nodemailer": "^7.0.4",
|
|
33
33
|
"prisma": "^6.7.0",
|
|
34
|
+
"pusher": "^5.2.0",
|
|
34
35
|
"sharp": "^0.34.2",
|
|
35
36
|
"socket.io": "^4.8.1",
|
|
36
37
|
"superjson": "^2.2.2",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- CreateEnum
|
|
2
|
+
CREATE TYPE "ConversationType" AS ENUM ('DM', 'GROUP');
|
|
3
|
+
|
|
4
|
+
-- CreateTable
|
|
5
|
+
CREATE TABLE "Conversation" (
|
|
6
|
+
"id" TEXT NOT NULL,
|
|
7
|
+
"type" "ConversationType" NOT NULL,
|
|
8
|
+
"name" TEXT,
|
|
9
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
11
|
+
|
|
12
|
+
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
-- CreateTable
|
|
16
|
+
CREATE TABLE "ConversationMember" (
|
|
17
|
+
"id" TEXT NOT NULL,
|
|
18
|
+
"userId" TEXT NOT NULL,
|
|
19
|
+
"conversationId" TEXT NOT NULL,
|
|
20
|
+
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
21
|
+
|
|
22
|
+
CONSTRAINT "ConversationMember_pkey" PRIMARY KEY ("id")
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
-- CreateTable
|
|
26
|
+
CREATE TABLE "Message" (
|
|
27
|
+
"id" TEXT NOT NULL,
|
|
28
|
+
"content" TEXT NOT NULL,
|
|
29
|
+
"senderId" TEXT NOT NULL,
|
|
30
|
+
"conversationId" TEXT NOT NULL,
|
|
31
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
32
|
+
|
|
33
|
+
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
-- CreateTable
|
|
37
|
+
CREATE TABLE "MessageRead" (
|
|
38
|
+
"id" TEXT NOT NULL,
|
|
39
|
+
"messageId" TEXT NOT NULL,
|
|
40
|
+
"userId" TEXT NOT NULL,
|
|
41
|
+
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
|
|
43
|
+
CONSTRAINT "MessageRead_pkey" PRIMARY KEY ("id")
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- CreateIndex
|
|
47
|
+
CREATE UNIQUE INDEX "ConversationMember_userId_conversationId_key" ON "ConversationMember"("userId", "conversationId");
|
|
48
|
+
|
|
49
|
+
-- CreateIndex
|
|
50
|
+
CREATE UNIQUE INDEX "MessageRead_messageId_userId_key" ON "MessageRead"("messageId", "userId");
|
|
51
|
+
|
|
52
|
+
-- AddForeignKey
|
|
53
|
+
ALTER TABLE "ConversationMember" ADD CONSTRAINT "ConversationMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
54
|
+
|
|
55
|
+
-- AddForeignKey
|
|
56
|
+
ALTER TABLE "ConversationMember" ADD CONSTRAINT "ConversationMember_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
57
|
+
|
|
58
|
+
-- AddForeignKey
|
|
59
|
+
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
60
|
+
|
|
61
|
+
-- AddForeignKey
|
|
62
|
+
ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
63
|
+
|
|
64
|
+
-- AddForeignKey
|
|
65
|
+
ALTER TABLE "MessageRead" ADD CONSTRAINT "MessageRead_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
66
|
+
|
|
67
|
+
-- AddForeignKey
|
|
68
|
+
ALTER TABLE "MessageRead" ADD CONSTRAINT "MessageRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Warnings:
|
|
3
|
+
|
|
4
|
+
- You are about to drop the `MessageRead` table. If the table is not empty, all the data it contains will be lost.
|
|
5
|
+
|
|
6
|
+
*/
|
|
7
|
+
-- DropForeignKey
|
|
8
|
+
ALTER TABLE "MessageRead" DROP CONSTRAINT "MessageRead_messageId_fkey";
|
|
9
|
+
|
|
10
|
+
-- DropForeignKey
|
|
11
|
+
ALTER TABLE "MessageRead" DROP CONSTRAINT "MessageRead_userId_fkey";
|
|
12
|
+
|
|
13
|
+
-- AlterTable
|
|
14
|
+
ALTER TABLE "Conversation" ADD COLUMN "displayInChat" BOOLEAN NOT NULL DEFAULT true;
|
|
15
|
+
|
|
16
|
+
-- AlterTable
|
|
17
|
+
ALTER TABLE "ConversationMember" ADD COLUMN "lastViewedAt" TIMESTAMP(3);
|
|
18
|
+
|
|
19
|
+
-- DropTable
|
|
20
|
+
DROP TABLE "MessageRead";
|