@studious-lms/server 1.1.9 → 1.1.11

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.
@@ -76,6 +76,40 @@ export declare const messageRouter: import("@trpc/server").TRPCBuiltRouter<{
76
76
  };
77
77
  meta: object;
78
78
  }>;
79
+ update: import("@trpc/server").TRPCMutationProcedure<{
80
+ input: {
81
+ content: string;
82
+ messageId: string;
83
+ mentionedUserIds?: string[] | undefined;
84
+ };
85
+ output: {
86
+ id: string;
87
+ content: string;
88
+ senderId: string;
89
+ conversationId: string;
90
+ createdAt: Date;
91
+ sender: {
92
+ id: string;
93
+ username: string;
94
+ profile: {
95
+ displayName: string | null;
96
+ profilePicture: string | null;
97
+ } | null;
98
+ };
99
+ mentionedUserIds: string[];
100
+ };
101
+ meta: object;
102
+ }>;
103
+ delete: import("@trpc/server").TRPCMutationProcedure<{
104
+ input: {
105
+ messageId: string;
106
+ };
107
+ output: {
108
+ success: boolean;
109
+ messageId: string;
110
+ };
111
+ meta: object;
112
+ }>;
79
113
  markAsRead: import("@trpc/server").TRPCMutationProcedure<{
80
114
  input: {
81
115
  conversationId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/routers/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsWxB,CAAC"}
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/routers/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6kBxB,CAAC"}
@@ -192,6 +192,207 @@ export const messageRouter = createTRPCRouter({
192
192
  mentionedUserIds,
193
193
  };
194
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
+ }),
195
396
  markAsRead: protectedProcedure
196
397
  .input(z.object({
197
398
  conversationId: z.string(),
@@ -305,14 +506,19 @@ export const messageRouter = createTRPCRouter({
305
506
  },
306
507
  });
307
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);
308
514
  const unreadMentionCount = await prisma.mention.count({
309
515
  where: {
310
516
  userId,
311
517
  message: {
312
518
  conversationId,
313
519
  senderId: { not: userId },
314
- ...(membership.lastViewedMentionAt && {
315
- createdAt: { gt: membership.lastViewedMentionAt }
520
+ ...(mentionCutoffTime && {
521
+ createdAt: { gt: mentionCutoffTime }
316
522
  }),
317
523
  },
318
524
  },
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqMrB,CAAC"}
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"}
@@ -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 signed URL for direct upload (write permission)
207
- const uploadUrl = await getSignedUrl(filePath, 'write', input.fileType);
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.9",
3
+ "version": "1.1.11",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
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
 
@@ -1093,6 +1093,13 @@ export const assignmentRouter = createTRPCRouter({
1093
1093
  select: {
1094
1094
  id: true,
1095
1095
  username: true,
1096
+ profile: {
1097
+ select: {
1098
+ displayName: true,
1099
+ profilePicture: true,
1100
+ profilePictureThumbnail: true,
1101
+ },
1102
+ },
1096
1103
  },
1097
1104
  },
1098
1105
  assignment: {
@@ -1195,6 +1202,13 @@ export const assignmentRouter = createTRPCRouter({
1195
1202
  select: {
1196
1203
  id: true,
1197
1204
  username: true,
1205
+ profile: {
1206
+ select: {
1207
+ displayName: true,
1208
+ profilePicture: true,
1209
+ profilePictureThumbnail: true,
1210
+ },
1211
+ },
1198
1212
  },
1199
1213
  },
1200
1214
  assignment: {
@@ -1311,6 +1325,13 @@ export const assignmentRouter = createTRPCRouter({
1311
1325
  select: {
1312
1326
  id: true,
1313
1327
  username: true,
1328
+ profile: {
1329
+ select: {
1330
+ displayName: true,
1331
+ profilePicture: true,
1332
+ profilePictureThumbnail: true,
1333
+ },
1334
+ },
1314
1335
  },
1315
1336
  },
1316
1337
  assignment: {
@@ -49,6 +49,17 @@ export const attendanceRouter = createTRPCRouter({
49
49
  students: {
50
50
  select: {
51
51
  id: true,
52
+ username: true,
53
+ profile: {
54
+ select: {
55
+ displayName: true,
56
+ profilePicture: true,
57
+ profilePictureThumbnail: true,
58
+ bio: true,
59
+ location: true,
60
+ website: true,
61
+ },
62
+ },
52
63
  },
53
64
  },
54
65
  },
@@ -117,18 +128,39 @@ export const attendanceRouter = createTRPCRouter({
117
128
  select: {
118
129
  id: true,
119
130
  username: true,
131
+ profile: {
132
+ select: {
133
+ displayName: true,
134
+ profilePicture: true,
135
+ profilePictureThumbnail: true,
136
+ },
137
+ },
120
138
  },
121
139
  },
122
140
  late: {
123
141
  select: {
124
142
  id: true,
125
143
  username: true,
144
+ profile: {
145
+ select: {
146
+ displayName: true,
147
+ profilePicture: true,
148
+ profilePictureThumbnail: true,
149
+ },
150
+ },
126
151
  },
127
152
  },
128
153
  absent: {
129
154
  select: {
130
155
  id: true,
131
156
  username: true,
157
+ profile: {
158
+ select: {
159
+ displayName: true,
160
+ profilePicture: true,
161
+ profilePictureThumbnail: true,
162
+ },
163
+ },
132
164
  },
133
165
  },
134
166
  },
@@ -104,6 +104,13 @@ export const classRouter = createTRPCRouter({
104
104
  select: {
105
105
  id: true,
106
106
  username: true,
107
+ profile: {
108
+ select: {
109
+ displayName: true,
110
+ profilePicture: true,
111
+ profilePictureThumbnail: true,
112
+ },
113
+ },
107
114
  },
108
115
  },
109
116
  announcements: {
@@ -118,6 +125,13 @@ export const classRouter = createTRPCRouter({
118
125
  select: {
119
126
  id: true,
120
127
  username: true,
128
+ profile: {
129
+ select: {
130
+ displayName: true,
131
+ profilePicture: true,
132
+ profilePictureThumbnail: true,
133
+ },
134
+ },
121
135
  },
122
136
  },
123
137
  },
@@ -76,14 +76,20 @@ export const conversationRouter = createTRPCRouter({
76
76
  });
77
77
 
78
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
+
79
85
  const unreadMentionCount = await prisma.mention.count({
80
86
  where: {
81
87
  userId,
82
88
  message: {
83
89
  conversationId: conversation.id,
84
90
  senderId: { not: userId },
85
- ...(lastViewedMentionAt && {
86
- createdAt: { gt: lastViewedMentionAt }
91
+ ...(mentionCutoffTime && {
92
+ createdAt: { gt: mentionCutoffTime }
87
93
  }),
88
94
  },
89
95
  },
@@ -181,12 +187,13 @@ export const conversationRouter = createTRPCRouter({
181
187
  // Verify all members exist
182
188
  const members = await prisma.user.findMany({
183
189
  where: {
184
- id: {
190
+ username: {
185
191
  in: memberIds,
186
192
  },
187
193
  },
188
194
  select: {
189
195
  id: true,
196
+ username: true,
190
197
  },
191
198
  });
192
199
 
@@ -209,7 +216,7 @@ export const conversationRouter = createTRPCRouter({
209
216
  role: type === 'GROUP' ? 'ADMIN' : 'MEMBER',
210
217
  },
211
218
  ...memberIds.map((memberId) => ({
212
- userId: memberId,
219
+ userId: members.find((member) => member.username === memberId)!.id,
213
220
  role: 'MEMBER' as const,
214
221
  })),
215
222
  ],