@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,325 @@
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
+ markAsRead: protectedProcedure
196
+ .input(z.object({
197
+ conversationId: z.string(),
198
+ }))
199
+ .mutation(async ({ input, ctx }) => {
200
+ const userId = ctx.user.id;
201
+ const { conversationId } = input;
202
+ // Verify user is a member of the conversation and update lastViewedAt
203
+ const membership = await prisma.conversationMember.findFirst({
204
+ where: {
205
+ conversationId,
206
+ userId,
207
+ },
208
+ });
209
+ if (!membership) {
210
+ throw new TRPCError({
211
+ code: 'FORBIDDEN',
212
+ message: 'Not a member of this conversation',
213
+ });
214
+ }
215
+ // Update the user's lastViewedAt timestamp for this conversation
216
+ await prisma.conversationMember.update({
217
+ where: {
218
+ id: membership.id,
219
+ },
220
+ data: {
221
+ lastViewedAt: new Date(),
222
+ },
223
+ });
224
+ // Broadcast that user has viewed the conversation
225
+ try {
226
+ await pusher.trigger(`conversation-${conversationId}`, 'conversation-viewed', {
227
+ userId,
228
+ viewedAt: new Date(),
229
+ });
230
+ }
231
+ catch (error) {
232
+ console.error('Failed to broadcast conversation view:', error);
233
+ // Don't fail the request if Pusher fails
234
+ }
235
+ return { success: true };
236
+ }),
237
+ markMentionsAsRead: protectedProcedure
238
+ .input(z.object({
239
+ conversationId: z.string(),
240
+ }))
241
+ .mutation(async ({ input, ctx }) => {
242
+ const userId = ctx.user.id;
243
+ const { conversationId } = input;
244
+ // Verify user is a member of the conversation and update lastViewedMentionAt
245
+ const membership = await prisma.conversationMember.findFirst({
246
+ where: {
247
+ conversationId,
248
+ userId,
249
+ },
250
+ });
251
+ if (!membership) {
252
+ throw new TRPCError({
253
+ code: 'FORBIDDEN',
254
+ message: 'Not a member of this conversation',
255
+ });
256
+ }
257
+ // Update the user's lastViewedMentionAt timestamp for this conversation
258
+ await prisma.conversationMember.update({
259
+ where: {
260
+ id: membership.id,
261
+ },
262
+ data: {
263
+ lastViewedMentionAt: new Date(),
264
+ },
265
+ });
266
+ // Broadcast that user has viewed mentions
267
+ try {
268
+ await pusher.trigger(`conversation-${conversationId}`, 'mentions-viewed', {
269
+ userId,
270
+ viewedAt: new Date(),
271
+ });
272
+ }
273
+ catch (error) {
274
+ console.error('Failed to broadcast mentions view:', error);
275
+ // Don't fail the request if Pusher fails
276
+ }
277
+ return { success: true };
278
+ }),
279
+ getUnreadCount: protectedProcedure
280
+ .input(z.object({ conversationId: z.string() }))
281
+ .query(async ({ input, ctx }) => {
282
+ const userId = ctx.user.id;
283
+ const { conversationId } = input;
284
+ // Get user's membership with lastViewedAt and lastViewedMentionAt
285
+ const membership = await prisma.conversationMember.findFirst({
286
+ where: {
287
+ conversationId,
288
+ userId,
289
+ },
290
+ });
291
+ if (!membership) {
292
+ throw new TRPCError({
293
+ code: 'FORBIDDEN',
294
+ message: 'Not a member of this conversation',
295
+ });
296
+ }
297
+ // Count regular unread messages
298
+ const unreadCount = await prisma.message.count({
299
+ where: {
300
+ conversationId,
301
+ senderId: { not: userId },
302
+ ...(membership.lastViewedAt && {
303
+ createdAt: { gt: membership.lastViewedAt }
304
+ }),
305
+ },
306
+ });
307
+ // Count unread mentions
308
+ const unreadMentionCount = await prisma.mention.count({
309
+ where: {
310
+ userId,
311
+ message: {
312
+ conversationId,
313
+ senderId: { not: userId },
314
+ ...(membership.lastViewedMentionAt && {
315
+ createdAt: { gt: membership.lastViewedMentionAt }
316
+ }),
317
+ },
318
+ },
319
+ });
320
+ return {
321
+ unreadCount,
322
+ unreadMentionCount
323
+ };
324
+ }),
325
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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,5 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "ConversationRole" AS ENUM ('ADMIN', 'MEMBER');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "ConversationMember" ADD COLUMN "role" "ConversationRole" NOT NULL DEFAULT 'MEMBER';
@@ -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";
@@ -0,0 +1,21 @@
1
+ -- AlterTable
2
+ ALTER TABLE "ConversationMember" ADD COLUMN "lastViewedMentionAt" TIMESTAMP(3);
3
+
4
+ -- CreateTable
5
+ CREATE TABLE "Mention" (
6
+ "id" TEXT NOT NULL,
7
+ "messageId" TEXT NOT NULL,
8
+ "userId" TEXT NOT NULL,
9
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+
11
+ CONSTRAINT "Mention_pkey" PRIMARY KEY ("id")
12
+ );
13
+
14
+ -- CreateIndex
15
+ CREATE UNIQUE INDEX "Mention_messageId_userId_key" ON "Mention"("messageId", "userId");
16
+
17
+ -- AddForeignKey
18
+ ALTER TABLE "Mention" ADD CONSTRAINT "Mention_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19
+
20
+ -- AddForeignKey
21
+ ALTER TABLE "Mention" ADD CONSTRAINT "Mention_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -76,6 +76,11 @@ model User {
76
76
  school School? @relation(fields: [schoolId], references: [id])
77
77
  schoolId String?
78
78
 
79
+ // Chat relations
80
+ conversationMemberships ConversationMember[]
81
+ sentMessages Message[] @relation("SentMessages")
82
+ mentions Mention[] @relation("UserMentions")
83
+
79
84
  }
80
85
 
81
86
  model UserProfile {
@@ -306,4 +311,66 @@ model Notification {
306
311
  read Boolean @default(false)
307
312
  sender User? @relation("SentNotifications", fields: [senderId], references: [id])
308
313
  receiver User @relation("ReceivedNotifications", fields: [receiverId], references: [id], onDelete: Cascade)
309
- }
314
+ }
315
+
316
+ enum ConversationType {
317
+ DM
318
+ GROUP
319
+ }
320
+
321
+ enum ConversationRole {
322
+ ADMIN
323
+ MEMBER
324
+ }
325
+
326
+ model Conversation {
327
+ id String @id @default(uuid())
328
+ type ConversationType
329
+ name String?
330
+ createdAt DateTime @default(now())
331
+ updatedAt DateTime @updatedAt
332
+
333
+ displayInChat Boolean @default(true)
334
+
335
+ members ConversationMember[]
336
+ messages Message[]
337
+ }
338
+
339
+ model ConversationMember {
340
+ id String @id @default(uuid())
341
+ userId String
342
+ conversationId String
343
+ role ConversationRole @default(MEMBER)
344
+ joinedAt DateTime @default(now())
345
+ lastViewedAt DateTime? // When user last viewed this conversation
346
+ lastViewedMentionAt DateTime? // When user last viewed mentions in this conversation
347
+
348
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
349
+ conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
350
+
351
+ @@unique([userId, conversationId])
352
+ }
353
+
354
+ model Message {
355
+ id String @id @default(uuid())
356
+ content String
357
+ senderId String
358
+ conversationId String
359
+ createdAt DateTime @default(now())
360
+
361
+ sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade)
362
+ conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
363
+ mentions Mention[]
364
+ }
365
+
366
+ model Mention {
367
+ id String @id @default(uuid())
368
+ messageId String
369
+ userId String
370
+ createdAt DateTime @default(now())
371
+
372
+ message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
373
+ user User @relation("UserMentions", fields: [userId], references: [id], onDelete: Cascade)
374
+
375
+ @@unique([messageId, userId])
376
+ }
@@ -0,0 +1,11 @@
1
+ import Pusher from 'pusher';
2
+
3
+ const pusher = new Pusher({
4
+ appId: process.env.PUSHER_APP_ID!,
5
+ key: process.env.PUSHER_KEY!,
6
+ secret: process.env.PUSHER_SECRET!,
7
+ cluster: process.env.PUSHER_CLUSTER!,
8
+ useTLS: true,
9
+ });
10
+
11
+ export { pusher };
@@ -13,6 +13,8 @@ import { agendaRouter } from "./agenda.js";
13
13
  import { fileRouter } from "./file.js";
14
14
  import { folderRouter } from "./folder.js";
15
15
  import { notificationRouter } from "./notifications.js";
16
+ import { conversationRouter } from "./conversation.js";
17
+ import { messageRouter } from "./message.js";
16
18
 
17
19
  export const appRouter = createTRPCRouter({
18
20
  class: classRouter,
@@ -27,6 +29,8 @@ export const appRouter = createTRPCRouter({
27
29
  file: fileRouter,
28
30
  folder: folderRouter,
29
31
  notification: notificationRouter,
32
+ conversation: conversationRouter,
33
+ message: messageRouter,
30
34
  });
31
35
 
32
36
  // Export type router type definition
@@ -197,6 +197,7 @@ export const authRouter = createTRPCRouter({
197
197
  return { success: true };
198
198
  }),
199
199
 
200
+
200
201
  check: protectedProcedure
201
202
  .query(async ({ ctx }) => {
202
203
  if (!ctx.user) {
@@ -54,6 +54,7 @@ export const classRouter = createTRPCRouter({
54
54
  id: true,
55
55
  title: true,
56
56
  type: true,
57
+ dueDate: true,
57
58
  },
58
59
  },
59
60
  },
@@ -547,6 +548,18 @@ export const classRouter = createTRPCRouter({
547
548
  title: true,
548
549
  maxGrade: true,
549
550
  weight: true,
551
+ markSchemeId: true,
552
+ markScheme: {
553
+ select: {
554
+ structured: true,
555
+ }
556
+ },
557
+ gradingBoundaryId: true,
558
+ gradingBoundary: {
559
+ select: {
560
+ structured: true,
561
+ }
562
+ },
550
563
  }
551
564
  },
552
565
  }