@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.
- package/CHAT_API_SPEC.md +579 -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 +496 -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 +10 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +13 -0
- package/dist/routers/conversation.d.ts +134 -0
- package/dist/routers/conversation.d.ts.map +1 -0
- package/dist/routers/conversation.js +261 -0
- package/dist/routers/message.d.ts +108 -0
- package/dist/routers/message.d.ts.map +1 -0
- package/dist/routers/message.js +325 -0
- 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/lib/pusher.ts +11 -0
- package/src/routers/_app.ts +4 -0
- package/src/routers/auth.ts +1 -0
- package/src/routers/class.ts +13 -0
- package/src/routers/conversation.ts +285 -0
- package/src/routers/message.ts +365 -0
|
@@ -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
|
+
});
|