@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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ app.use(cors({
22
22
  'http://localhost:3001', // Server port
23
23
  'http://127.0.0.1:3000', // Alternative localhost
24
24
  'http://127.0.0.1:3001', // Alternative localhost
25
+ 'https://www.studious.sh', // Production frontend
26
+ 'https://studious.sh', // Production frontend (without www)
25
27
  process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
26
28
  ],
27
29
  credentials: true,
@@ -37,6 +39,8 @@ app.options('*', (req, res) => {
37
39
  'http://localhost:3001',
38
40
  'http://127.0.0.1:3000',
39
41
  'http://127.0.0.1:3001',
42
+ 'https://www.studious.sh', // Production frontend
43
+ 'https://studious.sh', // Production frontend (without www)
40
44
  process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
41
45
  ];
42
46
 
@@ -92,6 +96,8 @@ const io = new Server(httpServer, {
92
96
  'http://localhost:3001', // Server port
93
97
  'http://127.0.0.1:3000', // Alternative localhost
94
98
  'http://127.0.0.1:3001', // Alternative localhost
99
+ 'https://www.studious.sh', // Production frontend
100
+ 'https://studious.sh', // Production frontend (without www)
95
101
  process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
96
102
  ],
97
103
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
@@ -158,6 +164,79 @@ app.get('/api/files/:filePath', async (req, res) => {
158
164
  }
159
165
  });
160
166
 
167
+ // File upload endpoint for secure file uploads (supports both POST and PUT)
168
+ app.post('/api/upload/:filePath', async (req, res) => {
169
+ handleFileUpload(req, res);
170
+ });
171
+
172
+ app.put('/api/upload/:filePath', async (req, res) => {
173
+ handleFileUpload(req, res);
174
+ });
175
+
176
+ function handleFileUpload(req: any, res: any) {
177
+ try {
178
+ const filePath = decodeURIComponent(req.params.filePath);
179
+ console.log('File upload request:', { filePath, originalPath: req.params.filePath, method: req.method });
180
+
181
+ // Set CORS headers for upload endpoint
182
+ const origin = req.headers.origin;
183
+ const allowedOrigins = [
184
+ 'http://localhost:3000',
185
+ 'http://localhost:3001',
186
+ 'http://127.0.0.1:3000',
187
+ 'http://127.0.0.1:3001',
188
+ 'https://www.studious.sh', // Production frontend
189
+ 'https://studious.sh', // Production frontend (without www)
190
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
191
+ ];
192
+
193
+ if (origin && allowedOrigins.includes(origin)) {
194
+ res.header('Access-Control-Allow-Origin', origin);
195
+ } else {
196
+ res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
197
+ }
198
+
199
+ res.header('Access-Control-Allow-Credentials', 'true');
200
+
201
+ // Get content type from headers
202
+ const contentType = req.headers['content-type'] || 'application/octet-stream';
203
+
204
+ // Create a new file in the bucket
205
+ const file = bucket.file(filePath);
206
+
207
+ // Create a write stream to Google Cloud Storage
208
+ const writeStream = file.createWriteStream({
209
+ metadata: {
210
+ contentType,
211
+ },
212
+ });
213
+
214
+ // Handle stream events
215
+ writeStream.on('error', (error) => {
216
+ console.error('Error uploading file:', error);
217
+ if (!res.headersSent) {
218
+ res.status(500).json({ error: 'Error uploading file' });
219
+ }
220
+ });
221
+
222
+ writeStream.on('finish', () => {
223
+ console.log('File uploaded successfully:', filePath);
224
+ res.status(200).json({
225
+ success: true,
226
+ filePath,
227
+ message: 'File uploaded successfully'
228
+ });
229
+ });
230
+
231
+ // Pipe the request body to the write stream
232
+ req.pipe(writeStream);
233
+
234
+ } catch (error) {
235
+ console.error('Error handling file upload:', error);
236
+ res.status(500).json({ error: 'Internal server error' });
237
+ }
238
+ }
239
+
161
240
  // Create caller
162
241
  const createCaller = createCallerFactory(appRouter);
163
242
 
@@ -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
  },
@@ -0,0 +1,292 @@
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
+ // Use the later of lastViewedAt or lastViewedMentionAt
80
+ // This means if user viewed conversation after mention, mention is considered read
81
+ const mentionCutoffTime = lastViewedMentionAt && lastViewedAt
82
+ ? (lastViewedMentionAt > lastViewedAt ? lastViewedMentionAt : lastViewedAt)
83
+ : (lastViewedMentionAt || lastViewedAt);
84
+
85
+ const unreadMentionCount = await prisma.mention.count({
86
+ where: {
87
+ userId,
88
+ message: {
89
+ conversationId: conversation.id,
90
+ senderId: { not: userId },
91
+ ...(mentionCutoffTime && {
92
+ createdAt: { gt: mentionCutoffTime }
93
+ }),
94
+ },
95
+ },
96
+ });
97
+
98
+ return {
99
+ id: conversation.id,
100
+ type: conversation.type,
101
+ name: conversation.name,
102
+ createdAt: conversation.createdAt,
103
+ updatedAt: conversation.updatedAt,
104
+ members: conversation.members,
105
+ lastMessage: conversation.messages[0] || null,
106
+ unreadCount,
107
+ unreadMentionCount,
108
+ };
109
+ })
110
+ );
111
+
112
+ return conversationsWithUnread;
113
+ }),
114
+
115
+ create: protectedProcedure
116
+ .input(
117
+ z.object({
118
+ type: z.enum(['DM', 'GROUP']),
119
+ name: z.string().optional(),
120
+ memberIds: z.array(z.string()),
121
+ })
122
+ )
123
+ .mutation(async ({ input, ctx }) => {
124
+ const userId = ctx.user!.id;
125
+ const { type, name, memberIds } = input;
126
+
127
+ // Validate input
128
+ if (type === 'GROUP' && !name) {
129
+ throw new TRPCError({
130
+ code: 'BAD_REQUEST',
131
+ message: 'Group conversations must have a name',
132
+ });
133
+ }
134
+
135
+ if (type === 'DM' && memberIds.length !== 1) {
136
+ throw new TRPCError({
137
+ code: 'BAD_REQUEST',
138
+ message: 'DM conversations must have exactly one other member',
139
+ });
140
+ }
141
+
142
+ // For DMs, check if conversation already exists
143
+ if (type === 'DM') {
144
+ const existingDM = await prisma.conversation.findFirst({
145
+ where: {
146
+ type: 'DM',
147
+ members: {
148
+ every: {
149
+ userId: {
150
+ in: [userId, memberIds[0]],
151
+ },
152
+ },
153
+ },
154
+ AND: {
155
+ members: {
156
+ some: {
157
+ userId,
158
+ },
159
+ },
160
+ },
161
+ },
162
+ include: {
163
+ members: {
164
+ include: {
165
+ user: {
166
+ select: {
167
+ id: true,
168
+ username: true,
169
+ profile: {
170
+ select: {
171
+ displayName: true,
172
+ profilePicture: true,
173
+ },
174
+ },
175
+ },
176
+ },
177
+ },
178
+ },
179
+ },
180
+ });
181
+
182
+ if (existingDM) {
183
+ return existingDM;
184
+ }
185
+ }
186
+
187
+ // Verify all members exist
188
+ const members = await prisma.user.findMany({
189
+ where: {
190
+ username: {
191
+ in: memberIds,
192
+ },
193
+ },
194
+ select: {
195
+ id: true,
196
+ username: true,
197
+ },
198
+ });
199
+
200
+ if (members.length !== memberIds.length) {
201
+ throw new TRPCError({
202
+ code: 'BAD_REQUEST',
203
+ message: 'One or more members not found',
204
+ });
205
+ }
206
+
207
+ // Create conversation with members
208
+ const conversation = await prisma.conversation.create({
209
+ data: {
210
+ type,
211
+ name,
212
+ members: {
213
+ create: [
214
+ {
215
+ userId,
216
+ role: type === 'GROUP' ? 'ADMIN' : 'MEMBER',
217
+ },
218
+ ...memberIds.map((memberId) => ({
219
+ userId: members.find((member) => member.username === memberId)!.id,
220
+ role: 'MEMBER' as const,
221
+ })),
222
+ ],
223
+ },
224
+ },
225
+ include: {
226
+ members: {
227
+ include: {
228
+ user: {
229
+ select: {
230
+ id: true,
231
+ username: true,
232
+ profile: {
233
+ select: {
234
+ displayName: true,
235
+ profilePicture: true,
236
+ },
237
+ },
238
+ },
239
+ },
240
+ },
241
+ },
242
+ },
243
+ });
244
+
245
+ return conversation;
246
+ }),
247
+
248
+ get: protectedProcedure
249
+ .input(z.object({ conversationId: z.string() }))
250
+ .query(async ({ input, ctx }) => {
251
+ const userId = ctx.user!.id;
252
+ const { conversationId } = input;
253
+
254
+ const conversation = await prisma.conversation.findFirst({
255
+ where: {
256
+ id: conversationId,
257
+ members: {
258
+ some: {
259
+ userId,
260
+ },
261
+ },
262
+ },
263
+ include: {
264
+ members: {
265
+ include: {
266
+ user: {
267
+ select: {
268
+ id: true,
269
+ username: true,
270
+ profile: {
271
+ select: {
272
+ displayName: true,
273
+ profilePicture: true,
274
+ },
275
+ },
276
+ },
277
+ },
278
+ },
279
+ },
280
+ },
281
+ });
282
+
283
+ if (!conversation) {
284
+ throw new TRPCError({
285
+ code: 'NOT_FOUND',
286
+ message: 'Conversation not found or access denied',
287
+ });
288
+ }
289
+
290
+ return conversation;
291
+ }),
292
+ });