@studious-lms/server 1.1.7 → 1.1.9

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,285 @@
1
+ import { z } from 'zod';
2
+ import { createTRPCRouter, protectedProcedure } from '../trpc.js';
3
+ import { prisma } from '../lib/prisma.js';
4
+ import { TRPCError } from '@trpc/server';
5
+
6
+ export const conversationRouter = createTRPCRouter({
7
+ list: protectedProcedure.query(async ({ ctx }) => {
8
+ const userId = ctx.user!.id;
9
+
10
+ const conversations = await prisma.conversation.findMany({
11
+ where: {
12
+ members: {
13
+ some: {
14
+ userId,
15
+ },
16
+ },
17
+ },
18
+ include: {
19
+ members: {
20
+ include: {
21
+ user: {
22
+ select: {
23
+ id: true,
24
+ username: true,
25
+ profile: {
26
+ select: {
27
+ displayName: true,
28
+ profilePicture: true,
29
+ },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ messages: {
36
+ orderBy: {
37
+ createdAt: 'desc',
38
+ },
39
+ take: 1,
40
+ include: {
41
+ sender: {
42
+ select: {
43
+ id: true,
44
+ username: true,
45
+ profile: {
46
+ select: {
47
+ displayName: true,
48
+ },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ orderBy: {
56
+ updatedAt: 'desc',
57
+ },
58
+ });
59
+
60
+ // Calculate unread counts for each conversation
61
+ const conversationsWithUnread = await Promise.all(
62
+ conversations.map(async (conversation) => {
63
+ const userMembership = conversation.members.find(m => m.userId === userId);
64
+ const lastViewedAt = userMembership?.lastViewedAt;
65
+ const lastViewedMentionAt = userMembership?.lastViewedMentionAt;
66
+
67
+ // Count regular unread messages
68
+ const unreadCount = await prisma.message.count({
69
+ where: {
70
+ conversationId: conversation.id,
71
+ senderId: { not: userId },
72
+ ...(lastViewedAt && {
73
+ createdAt: { gt: lastViewedAt }
74
+ }),
75
+ },
76
+ });
77
+
78
+ // Count unread mentions
79
+ const unreadMentionCount = await prisma.mention.count({
80
+ where: {
81
+ userId,
82
+ message: {
83
+ conversationId: conversation.id,
84
+ senderId: { not: userId },
85
+ ...(lastViewedMentionAt && {
86
+ createdAt: { gt: lastViewedMentionAt }
87
+ }),
88
+ },
89
+ },
90
+ });
91
+
92
+ return {
93
+ id: conversation.id,
94
+ type: conversation.type,
95
+ name: conversation.name,
96
+ createdAt: conversation.createdAt,
97
+ updatedAt: conversation.updatedAt,
98
+ members: conversation.members,
99
+ lastMessage: conversation.messages[0] || null,
100
+ unreadCount,
101
+ unreadMentionCount,
102
+ };
103
+ })
104
+ );
105
+
106
+ return conversationsWithUnread;
107
+ }),
108
+
109
+ create: protectedProcedure
110
+ .input(
111
+ z.object({
112
+ type: z.enum(['DM', 'GROUP']),
113
+ name: z.string().optional(),
114
+ memberIds: z.array(z.string()),
115
+ })
116
+ )
117
+ .mutation(async ({ input, ctx }) => {
118
+ const userId = ctx.user!.id;
119
+ const { type, name, memberIds } = input;
120
+
121
+ // Validate input
122
+ if (type === 'GROUP' && !name) {
123
+ throw new TRPCError({
124
+ code: 'BAD_REQUEST',
125
+ message: 'Group conversations must have a name',
126
+ });
127
+ }
128
+
129
+ if (type === 'DM' && memberIds.length !== 1) {
130
+ throw new TRPCError({
131
+ code: 'BAD_REQUEST',
132
+ message: 'DM conversations must have exactly one other member',
133
+ });
134
+ }
135
+
136
+ // For DMs, check if conversation already exists
137
+ if (type === 'DM') {
138
+ const existingDM = await prisma.conversation.findFirst({
139
+ where: {
140
+ type: 'DM',
141
+ members: {
142
+ every: {
143
+ userId: {
144
+ in: [userId, memberIds[0]],
145
+ },
146
+ },
147
+ },
148
+ AND: {
149
+ members: {
150
+ some: {
151
+ userId,
152
+ },
153
+ },
154
+ },
155
+ },
156
+ include: {
157
+ members: {
158
+ include: {
159
+ user: {
160
+ select: {
161
+ id: true,
162
+ username: true,
163
+ profile: {
164
+ select: {
165
+ displayName: true,
166
+ profilePicture: true,
167
+ },
168
+ },
169
+ },
170
+ },
171
+ },
172
+ },
173
+ },
174
+ });
175
+
176
+ if (existingDM) {
177
+ return existingDM;
178
+ }
179
+ }
180
+
181
+ // Verify all members exist
182
+ const members = await prisma.user.findMany({
183
+ where: {
184
+ id: {
185
+ in: memberIds,
186
+ },
187
+ },
188
+ select: {
189
+ id: true,
190
+ },
191
+ });
192
+
193
+ if (members.length !== memberIds.length) {
194
+ throw new TRPCError({
195
+ code: 'BAD_REQUEST',
196
+ message: 'One or more members not found',
197
+ });
198
+ }
199
+
200
+ // Create conversation with members
201
+ const conversation = await prisma.conversation.create({
202
+ data: {
203
+ type,
204
+ name,
205
+ members: {
206
+ create: [
207
+ {
208
+ userId,
209
+ role: type === 'GROUP' ? 'ADMIN' : 'MEMBER',
210
+ },
211
+ ...memberIds.map((memberId) => ({
212
+ userId: memberId,
213
+ role: 'MEMBER' as const,
214
+ })),
215
+ ],
216
+ },
217
+ },
218
+ include: {
219
+ members: {
220
+ include: {
221
+ user: {
222
+ select: {
223
+ id: true,
224
+ username: true,
225
+ profile: {
226
+ select: {
227
+ displayName: true,
228
+ profilePicture: true,
229
+ },
230
+ },
231
+ },
232
+ },
233
+ },
234
+ },
235
+ },
236
+ });
237
+
238
+ return conversation;
239
+ }),
240
+
241
+ get: protectedProcedure
242
+ .input(z.object({ conversationId: z.string() }))
243
+ .query(async ({ input, ctx }) => {
244
+ const userId = ctx.user!.id;
245
+ const { conversationId } = input;
246
+
247
+ const conversation = await prisma.conversation.findFirst({
248
+ where: {
249
+ id: conversationId,
250
+ members: {
251
+ some: {
252
+ userId,
253
+ },
254
+ },
255
+ },
256
+ include: {
257
+ members: {
258
+ include: {
259
+ user: {
260
+ select: {
261
+ id: true,
262
+ username: true,
263
+ profile: {
264
+ select: {
265
+ displayName: true,
266
+ profilePicture: true,
267
+ },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ },
273
+ },
274
+ });
275
+
276
+ if (!conversation) {
277
+ throw new TRPCError({
278
+ code: 'NOT_FOUND',
279
+ message: 'Conversation not found or access denied',
280
+ });
281
+ }
282
+
283
+ return conversation;
284
+ }),
285
+ });
@@ -0,0 +1,365 @@
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
+
216
+ markAsRead: protectedProcedure
217
+ .input(
218
+ z.object({
219
+ conversationId: z.string(),
220
+ })
221
+ )
222
+ .mutation(async ({ input, ctx }) => {
223
+ const userId = ctx.user!.id;
224
+ const { conversationId } = input;
225
+
226
+ // Verify user is a member of the conversation and update lastViewedAt
227
+ const membership = await prisma.conversationMember.findFirst({
228
+ where: {
229
+ conversationId,
230
+ userId,
231
+ },
232
+ });
233
+
234
+ if (!membership) {
235
+ throw new TRPCError({
236
+ code: 'FORBIDDEN',
237
+ message: 'Not a member of this conversation',
238
+ });
239
+ }
240
+
241
+ // Update the user's lastViewedAt timestamp for this conversation
242
+ await prisma.conversationMember.update({
243
+ where: {
244
+ id: membership.id,
245
+ },
246
+ data: {
247
+ lastViewedAt: new Date(),
248
+ },
249
+ });
250
+
251
+ // Broadcast that user has viewed the conversation
252
+ try {
253
+ await pusher.trigger(`conversation-${conversationId}`, 'conversation-viewed', {
254
+ userId,
255
+ viewedAt: new Date(),
256
+ });
257
+ } catch (error) {
258
+ console.error('Failed to broadcast conversation view:', error);
259
+ // Don't fail the request if Pusher fails
260
+ }
261
+
262
+ return { success: true };
263
+ }),
264
+
265
+ markMentionsAsRead: protectedProcedure
266
+ .input(
267
+ z.object({
268
+ conversationId: z.string(),
269
+ })
270
+ )
271
+ .mutation(async ({ input, ctx }) => {
272
+ const userId = ctx.user!.id;
273
+ const { conversationId } = input;
274
+
275
+ // Verify user is a member of the conversation and update lastViewedMentionAt
276
+ const membership = await prisma.conversationMember.findFirst({
277
+ where: {
278
+ conversationId,
279
+ userId,
280
+ },
281
+ });
282
+
283
+ if (!membership) {
284
+ throw new TRPCError({
285
+ code: 'FORBIDDEN',
286
+ message: 'Not a member of this conversation',
287
+ });
288
+ }
289
+
290
+ // Update the user's lastViewedMentionAt timestamp for this conversation
291
+ await prisma.conversationMember.update({
292
+ where: {
293
+ id: membership.id,
294
+ },
295
+ data: {
296
+ lastViewedMentionAt: new Date(),
297
+ },
298
+ });
299
+
300
+ // Broadcast that user has viewed mentions
301
+ try {
302
+ await pusher.trigger(`conversation-${conversationId}`, 'mentions-viewed', {
303
+ userId,
304
+ viewedAt: new Date(),
305
+ });
306
+ } catch (error) {
307
+ console.error('Failed to broadcast mentions view:', error);
308
+ // Don't fail the request if Pusher fails
309
+ }
310
+
311
+ return { success: true };
312
+ }),
313
+
314
+ getUnreadCount: protectedProcedure
315
+ .input(z.object({ conversationId: z.string() }))
316
+ .query(async ({ input, ctx }) => {
317
+ const userId = ctx.user!.id;
318
+ const { conversationId } = input;
319
+
320
+ // Get user's membership with lastViewedAt and lastViewedMentionAt
321
+ const membership = await prisma.conversationMember.findFirst({
322
+ where: {
323
+ conversationId,
324
+ userId,
325
+ },
326
+ });
327
+
328
+ if (!membership) {
329
+ throw new TRPCError({
330
+ code: 'FORBIDDEN',
331
+ message: 'Not a member of this conversation',
332
+ });
333
+ }
334
+
335
+ // Count regular unread messages
336
+ const unreadCount = await prisma.message.count({
337
+ where: {
338
+ conversationId,
339
+ senderId: { not: userId },
340
+ ...(membership.lastViewedAt && {
341
+ createdAt: { gt: membership.lastViewedAt }
342
+ }),
343
+ },
344
+ });
345
+
346
+ // Count unread mentions
347
+ const unreadMentionCount = await prisma.mention.count({
348
+ where: {
349
+ userId,
350
+ message: {
351
+ conversationId,
352
+ senderId: { not: userId },
353
+ ...(membership.lastViewedMentionAt && {
354
+ createdAt: { gt: membership.lastViewedMentionAt }
355
+ }),
356
+ },
357
+ },
358
+ });
359
+
360
+ return {
361
+ unreadCount,
362
+ unreadMentionCount
363
+ };
364
+ }),
365
+ });