@studious-lms/server 1.4.4 → 1.4.6

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.
Files changed (93) hide show
  1. package/dist/index.js +3 -17
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/config/cors.d.ts.map +1 -1
  4. package/dist/lib/config/cors.js +8 -3
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/models/comment.d.ts +163 -0
  7. package/dist/models/comment.d.ts.map +1 -1
  8. package/dist/models/comment.js +145 -3
  9. package/dist/models/comment.js.map +1 -1
  10. package/dist/models/conversation.d.ts +22 -0
  11. package/dist/models/conversation.d.ts.map +1 -1
  12. package/dist/models/conversation.js +13 -2
  13. package/dist/models/conversation.js.map +1 -1
  14. package/dist/models/labChat.d.ts +3 -0
  15. package/dist/models/labChat.d.ts.map +1 -1
  16. package/dist/models/message.d.ts +2 -0
  17. package/dist/models/message.d.ts.map +1 -1
  18. package/dist/models/newtonChat.d.ts +1 -0
  19. package/dist/models/newtonChat.d.ts.map +1 -1
  20. package/dist/models/worksheet.d.ts +2 -0
  21. package/dist/models/worksheet.d.ts.map +1 -1
  22. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  23. package/dist/pipelines/aiLabChat.js +4 -3
  24. package/dist/pipelines/aiLabChat.js.map +1 -1
  25. package/dist/routers/_app.d.ts +665 -1
  26. package/dist/routers/_app.d.ts.map +1 -1
  27. package/dist/routers/_app.js +4 -2
  28. package/dist/routers/_app.js.map +1 -1
  29. package/dist/routers/announcement.d.ts +8 -0
  30. package/dist/routers/announcement.d.ts.map +1 -1
  31. package/dist/routers/assignment.d.ts +230 -0
  32. package/dist/routers/assignment.d.ts.map +1 -1
  33. package/dist/routers/assignment.js +207 -2
  34. package/dist/routers/assignment.js.map +1 -1
  35. package/dist/routers/comment.d.ts +37 -0
  36. package/dist/routers/comment.d.ts.map +1 -1
  37. package/dist/routers/comment.js +14 -5
  38. package/dist/routers/comment.js.map +1 -1
  39. package/dist/routers/conversation.d.ts +14 -0
  40. package/dist/routers/conversation.d.ts.map +1 -1
  41. package/dist/routers/conversation.js +6 -3
  42. package/dist/routers/conversation.js.map +1 -1
  43. package/dist/routers/labChat.d.ts +2 -0
  44. package/dist/routers/labChat.d.ts.map +1 -1
  45. package/dist/routers/search.d.ts +41 -0
  46. package/dist/routers/search.d.ts.map +1 -0
  47. package/dist/routers/search.js +15 -0
  48. package/dist/routers/search.js.map +1 -0
  49. package/dist/routers/worksheet.d.ts +2 -0
  50. package/dist/routers/worksheet.d.ts.map +1 -1
  51. package/dist/services/comment.d.ts +28 -2
  52. package/dist/services/comment.d.ts.map +1 -1
  53. package/dist/services/comment.js +97 -19
  54. package/dist/services/comment.js.map +1 -1
  55. package/dist/services/conversation.d.ts +8 -0
  56. package/dist/services/conversation.d.ts.map +1 -1
  57. package/dist/services/conversation.js +24 -6
  58. package/dist/services/conversation.js.map +1 -1
  59. package/dist/services/labChat.d.ts +2 -0
  60. package/dist/services/labChat.d.ts.map +1 -1
  61. package/dist/services/message.d.ts.map +1 -1
  62. package/dist/services/message.js +9 -2
  63. package/dist/services/message.js.map +1 -1
  64. package/dist/services/search.d.ts +21 -0
  65. package/dist/services/search.d.ts.map +1 -0
  66. package/dist/services/search.js +352 -0
  67. package/dist/services/search.js.map +1 -0
  68. package/dist/services/worksheet.d.ts +2 -0
  69. package/dist/services/worksheet.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
  72. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +60 -0
  73. package/prisma/schema.prisma +10 -0
  74. package/src/index.ts +1 -17
  75. package/src/lib/config/cors.ts +8 -1
  76. package/src/models/comment.ts +148 -1
  77. package/src/models/conversation.ts +12 -0
  78. package/src/pipelines/aiLabChat.ts +2 -1
  79. package/src/routers/_app.ts +2 -0
  80. package/src/routers/assignment.ts +224 -1
  81. package/src/routers/comment.ts +19 -2
  82. package/src/routers/conversation.ts +7 -0
  83. package/src/routers/search.ts +14 -0
  84. package/src/services/comment.ts +113 -18
  85. package/src/services/conversation.ts +27 -3
  86. package/src/services/message.ts +8 -0
  87. package/src/services/search.ts +397 -0
  88. package/tests/lib/cors.test.ts +13 -1
  89. package/tests/routers/assignment.test.ts +68 -2
  90. package/tests/routers/comment.test.ts +42 -1
  91. package/tests/routers/conversation.test.ts +114 -2
  92. package/tests/routers/search.test.ts +109 -0
  93. package/vercel.json +8 -0
@@ -0,0 +1,60 @@
1
+ ALTER TABLE "public"."Comment" ADD COLUMN "assignmentId" TEXT;
2
+ ALTER TABLE "public"."Comment" ADD COLUMN "submissionId" TEXT;
3
+
4
+ CREATE INDEX "Comment_assignmentId_idx" ON "public"."Comment"("assignmentId");
5
+ CREATE INDEX "Comment_submissionId_idx" ON "public"."Comment"("submissionId");
6
+
7
+ ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "public"."Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
8
+ ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "public"."Submission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
9
+
10
+ WITH RECURSIVE "CommentOwnerContext" AS (
11
+ SELECT
12
+ "id",
13
+ "parentCommentId",
14
+ "announcementId",
15
+ "assignmentId",
16
+ "submissionId",
17
+ "studentQuestionProgressId"
18
+ FROM "public"."Comment"
19
+ WHERE "parentCommentId" IS NULL
20
+
21
+ UNION ALL
22
+
23
+ SELECT
24
+ child."id",
25
+ child."parentCommentId",
26
+ COALESCE(child."announcementId", parent."announcementId"),
27
+ COALESCE(child."assignmentId", parent."assignmentId"),
28
+ COALESCE(child."submissionId", parent."submissionId"),
29
+ COALESCE(child."studentQuestionProgressId", parent."studentQuestionProgressId")
30
+ FROM "public"."Comment" child
31
+ INNER JOIN "CommentOwnerContext" parent ON child."parentCommentId" = parent."id"
32
+ )
33
+ UPDATE "public"."Comment" c
34
+ SET
35
+ "announcementId" = owner."announcementId",
36
+ "assignmentId" = owner."assignmentId",
37
+ "submissionId" = owner."submissionId",
38
+ "studentQuestionProgressId" = owner."studentQuestionProgressId"
39
+ FROM "CommentOwnerContext" owner
40
+ WHERE c."id" = owner."id"
41
+ AND c."parentCommentId" IS NOT NULL
42
+ AND (
43
+ (CASE WHEN c."assignmentId" IS NOT NULL THEN 1 ELSE 0 END) +
44
+ (CASE WHEN c."submissionId" IS NOT NULL THEN 1 ELSE 0 END) +
45
+ (CASE WHEN c."announcementId" IS NOT NULL THEN 1 ELSE 0 END) +
46
+ (CASE WHEN c."studentQuestionProgressId" IS NOT NULL THEN 1 ELSE 0 END)
47
+ ) = 0
48
+ AND (
49
+ (CASE WHEN owner."assignmentId" IS NOT NULL THEN 1 ELSE 0 END) +
50
+ (CASE WHEN owner."submissionId" IS NOT NULL THEN 1 ELSE 0 END) +
51
+ (CASE WHEN owner."announcementId" IS NOT NULL THEN 1 ELSE 0 END) +
52
+ (CASE WHEN owner."studentQuestionProgressId" IS NOT NULL THEN 1 ELSE 0 END)
53
+ ) = 1;
54
+
55
+ ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_single_owner_check" CHECK (
56
+ (CASE WHEN "assignmentId" IS NOT NULL THEN 1 ELSE 0 END) +
57
+ (CASE WHEN "submissionId" IS NOT NULL THEN 1 ELSE 0 END) +
58
+ (CASE WHEN "announcementId" IS NOT NULL THEN 1 ELSE 0 END) +
59
+ (CASE WHEN "studentQuestionProgressId" IS NOT NULL THEN 1 ELSE 0 END) = 1
60
+ );
@@ -282,6 +282,7 @@ model Assignment {
282
282
  aiPolicyLevel Int @default(0)
283
283
 
284
284
  submissions Submission[]
285
+ comments Comment[] @relation("AssignmentComments")
285
286
  assignedTo User[] @relation("SpecificAssignmentAssignedTo")
286
287
  section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
287
288
  sectionId String?
@@ -325,6 +326,10 @@ model Comment {
325
326
  authorId String
326
327
  announcement Announcement? @relation(fields: [announcementId], references: [id], onDelete: Cascade)
327
328
  announcementId String?
329
+ assignment Assignment? @relation("AssignmentComments", fields: [assignmentId], references: [id], onDelete: Cascade)
330
+ assignmentId String?
331
+ submission Submission? @relation("SubmissionComments", fields: [submissionId], references: [id], onDelete: Cascade)
332
+ submissionId String?
328
333
  parentComment Comment? @relation("CommentReplies", fields: [parentCommentId], references: [id], onDelete: Cascade)
329
334
  parentCommentId String?
330
335
  status GenerationStatus?
@@ -334,6 +339,9 @@ model Comment {
334
339
  modifiedAt DateTime? @updatedAt
335
340
  studentQuestionProgress StudentQuestionProgress? @relation("StudentQuestionProgressComments", fields: [studentQuestionProgressId], references: [id], onDelete: Cascade)
336
341
  studentQuestionProgressId String?
342
+
343
+ @@index([assignmentId])
344
+ @@index([submissionId])
337
345
  }
338
346
 
339
347
  model Reaction {
@@ -366,6 +374,7 @@ model Submission {
366
374
 
367
375
  attachments File[] @relation("SubmissionFile")
368
376
  annotations File[] @relation("SubmissionAnnotations")
377
+ comments Comment[] @relation("SubmissionComments")
369
378
  worksheetResponses StudentWorksheetResponse[]
370
379
  extendedResponse String?
371
380
  gradeReceived Int?
@@ -512,6 +521,7 @@ model ConversationMember {
512
521
  conversationId String
513
522
  role ConversationRole @default(MEMBER)
514
523
  joinedAt DateTime @default(now())
524
+ hiddenAt DateTime? // When user last hid this conversation from their list
515
525
  lastViewedAt DateTime? // When user last viewed this conversation
516
526
  lastViewedMentionAt DateTime? // When user last viewed mentions in this conversation
517
527
 
package/src/index.ts CHANGED
@@ -520,22 +520,6 @@ async function handleFileUpload(req: Request, res: Response) {
520
520
  }
521
521
 
522
522
  res.header('Access-Control-Allow-Credentials', 'true');
523
-
524
- const user = await getAuthenticatedRequestUser(req);
525
- if (!user) {
526
- return res.status(401).json({ error: 'Authentication required' });
527
- }
528
-
529
- const uploadSessionId = getUploadSessionId(req);
530
- if (!uploadSessionId) {
531
- return res.status(401).json({ error: 'Upload session is required' });
532
- }
533
-
534
- await validateDirectUploadIntent({
535
- filePath,
536
- uploadSessionId,
537
- userId: user.id,
538
- });
539
523
 
540
524
  const contentType = req.headers['content-type'] || 'application/octet-stream';
541
525
  const file = bucket.file(filePath);
@@ -547,7 +531,7 @@ async function handleFileUpload(req: Request, res: Response) {
547
531
  });
548
532
 
549
533
  writeStream.on('error', (error) => {
550
- logger.error('Error uploading file:', {error, filePath, userId: user.id});
534
+ logger.error('Error uploading file:', {error, filePath});
551
535
  if (!res.headersSent) {
552
536
  res.status(500).json({ error: 'Error uploading file' });
553
537
  }
@@ -18,6 +18,10 @@ const nonProductionOrigins = [
18
18
  'http://127.0.0.1:3000',
19
19
  'http://127.0.0.1:3001',
20
20
  ];
21
+
22
+ /** Any preview / prod deployment on *.vercel.app (HTTPS only). */
23
+ const vercelAppOriginPattern = /^https:\/\/[a-z0-9][a-z0-9.-]*\.vercel\.app$/i;
24
+
21
25
  const MAX_PATTERN_LENGTH = 200;
22
26
  // This heuristic only targets common nested-quantifier cases like `(a+)+`.
23
27
  // It does not catch every risky construct (for example `.*.*` or deep alternation nesting),
@@ -77,7 +81,10 @@ export const createCorsOriginMatcher = (env: CorsEnv) => {
77
81
  ...parseCorsOrigins(env.CORS_ALLOWED_ORIGINS),
78
82
  ].filter(isDefined)));
79
83
 
80
- const allowedOriginPatterns = parseCorsOriginPatterns(env.CORS_ALLOWED_ORIGIN_PATTERNS);
84
+ const allowedOriginPatterns = [
85
+ vercelAppOriginPattern,
86
+ ...parseCorsOriginPatterns(env.CORS_ALLOWED_ORIGIN_PATTERNS),
87
+ ];
81
88
 
82
89
  const isAllowedOrigin = (origin?: string) => {
83
90
  const corsOrigin = origin ? toCorsOrigin(origin) : undefined;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Comment model – comments, replies, and reactions (for announcements/worksheets).
2
+ * Comment model – comments, replies, and reactions.
3
3
  */
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import type { ReactionType } from "@prisma/client";
@@ -7,6 +7,10 @@ import type { ReactionType } from "@prisma/client";
7
7
  export const commentSelect = {
8
8
  id: true,
9
9
  content: true,
10
+ announcementId: true,
11
+ assignmentId: true,
12
+ submissionId: true,
13
+ parentCommentId: true,
10
14
  createdAt: true,
11
15
  modifiedAt: true,
12
16
  author: {
@@ -77,6 +81,10 @@ export function createComment(data: {
77
81
  parentCommentId: string;
78
82
  content: string;
79
83
  authorId: string;
84
+ announcementId?: string | null;
85
+ assignmentId?: string | null;
86
+ submissionId?: string | null;
87
+ studentQuestionProgressId?: string | null;
80
88
  }) {
81
89
  return prisma.comment.create({
82
90
  data,
@@ -142,6 +150,145 @@ export function findCommentWithAnnouncement(commentId: string) {
142
150
  });
143
151
  }
144
152
 
153
+ /** @returns Comment with the owning class context for authorization. */
154
+ export function findCommentWithContext(commentId: string) {
155
+ return prisma.comment.findUnique({
156
+ where: { id: commentId },
157
+ include: {
158
+ announcement: {
159
+ select: {
160
+ class: {
161
+ select: {
162
+ id: true,
163
+ teachers: { select: { id: true } },
164
+ students: { select: { id: true } },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ assignment: {
170
+ select: {
171
+ class: {
172
+ select: {
173
+ id: true,
174
+ teachers: { select: { id: true } },
175
+ students: { select: { id: true } },
176
+ },
177
+ },
178
+ },
179
+ },
180
+ submission: {
181
+ select: {
182
+ studentId: true,
183
+ assignment: {
184
+ select: {
185
+ class: {
186
+ select: {
187
+ id: true,
188
+ teachers: { select: { id: true } },
189
+ students: { select: { id: true } },
190
+ },
191
+ },
192
+ },
193
+ },
194
+ },
195
+ },
196
+ studentQuestionProgress: {
197
+ select: {
198
+ studentId: true,
199
+ studentWorksheetResponse: {
200
+ select: {
201
+ submission: {
202
+ select: {
203
+ studentId: true,
204
+ assignment: {
205
+ select: {
206
+ class: {
207
+ select: {
208
+ id: true,
209
+ teachers: { select: { id: true } },
210
+ students: { select: { id: true } },
211
+ },
212
+ },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ parentComment: {
222
+ include: {
223
+ announcement: {
224
+ select: {
225
+ class: {
226
+ select: {
227
+ id: true,
228
+ teachers: { select: { id: true } },
229
+ students: { select: { id: true } },
230
+ },
231
+ },
232
+ },
233
+ },
234
+ assignment: {
235
+ select: {
236
+ class: {
237
+ select: {
238
+ id: true,
239
+ teachers: { select: { id: true } },
240
+ students: { select: { id: true } },
241
+ },
242
+ },
243
+ },
244
+ },
245
+ submission: {
246
+ select: {
247
+ studentId: true,
248
+ assignment: {
249
+ select: {
250
+ class: {
251
+ select: {
252
+ id: true,
253
+ teachers: { select: { id: true } },
254
+ students: { select: { id: true } },
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ },
261
+ studentQuestionProgress: {
262
+ select: {
263
+ studentId: true,
264
+ studentWorksheetResponse: {
265
+ select: {
266
+ submission: {
267
+ select: {
268
+ studentId: true,
269
+ assignment: {
270
+ select: {
271
+ class: {
272
+ select: {
273
+ id: true,
274
+ teachers: { select: { id: true } },
275
+ students: { select: { id: true } },
276
+ },
277
+ },
278
+ },
279
+ },
280
+ },
281
+ },
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ },
288
+ },
289
+ });
290
+ }
291
+
145
292
  /** @returns Reaction counts by type for a comment. */
146
293
  export function getReactionCountsByCommentId(commentId: string) {
147
294
  return prisma.reaction.groupBy({
@@ -190,6 +190,18 @@ export function createConversationMember(data: {
190
190
  });
191
191
  }
192
192
 
193
+ /** Hide a conversation for a single member until new activity occurs. */
194
+ export function hideConversationMember(userId: string, conversationId: string) {
195
+ return prisma.conversationMember.update({
196
+ where: {
197
+ userId_conversationId: { userId, conversationId },
198
+ },
199
+ data: {
200
+ hiddenAt: new Date(),
201
+ },
202
+ });
203
+ }
204
+
193
205
  /** Remove a member from a conversation. */
194
206
  export function deleteConversationMember(userId: string, conversationId: string) {
195
207
  return prisma.conversationMember.delete({
@@ -16,7 +16,8 @@ import { DocumentBlock } from "../lib/jsonStyles.js";
16
16
  import { type LabChatResponse, labChatResponseSchema } from "./aiLabChatContract.js";
17
17
  import { buildLabChatResponseMessages } from "./labChatPrompt.js";
18
18
 
19
- const LAB_CHAT_RESPONSE_TIMEOUT_MS = 90_000;
19
+ /** Cap model wait time; leave headroom after inference for PDF/GCS/DB within Vercel maxDuration (500s). */
20
+ const LAB_CHAT_RESPONSE_TIMEOUT_MS = 400_000;
20
21
 
21
22
  const withTimeout = async <T>(
22
23
  task: Promise<T>,
@@ -21,6 +21,7 @@ import { marketingRouter } from "./marketing.js";
21
21
  import { worksheetRouter } from "./worksheet.js";
22
22
  import { commentRouter } from "./comment.js";
23
23
  import { studentProgressRouter } from "./studentProgress.js";
24
+ import { searchRouter } from "./search.js";
24
25
 
25
26
  export const appRouter = createTRPCRouter({
26
27
  class: classRouter,
@@ -43,6 +44,7 @@ export const appRouter = createTRPCRouter({
43
44
  worksheet: worksheetRouter,
44
45
  comment: commentRouter,
45
46
  studentProgress: studentProgressRouter,
47
+ search: searchRouter,
46
48
  });
47
49
 
48
50
  // Export type router type definition
@@ -172,6 +172,66 @@ const updateUploadProgressSchema = z.object({
172
172
  progress: z.number().min(0).max(100),
173
173
  });
174
174
 
175
+ const assignmentCommentSchema = z.object({
176
+ assignmentId: z.string(),
177
+ classId: z.string(),
178
+ content: z.string().min(1, "Comment cannot be empty"),
179
+ parentCommentId: z.string().optional(),
180
+ });
181
+
182
+ const assignmentCommentsSchema = z.object({
183
+ assignmentId: z.string(),
184
+ classId: z.string(),
185
+ });
186
+
187
+ const submissionCommentSchema = z.object({
188
+ assignmentId: z.string(),
189
+ classId: z.string(),
190
+ submissionId: z.string(),
191
+ content: z.string().min(1, "Comment cannot be empty"),
192
+ parentCommentId: z.string().optional(),
193
+ });
194
+
195
+ const submissionCommentsSchema = z.object({
196
+ assignmentId: z.string(),
197
+ classId: z.string(),
198
+ submissionId: z.string(),
199
+ });
200
+
201
+ const commentInclude = {
202
+ author: {
203
+ select: {
204
+ id: true,
205
+ username: true,
206
+ profile: {
207
+ select: {
208
+ displayName: true,
209
+ profilePicture: true,
210
+ profilePictureThumbnail: true,
211
+ },
212
+ },
213
+ },
214
+ },
215
+ replies: {
216
+ include: {
217
+ author: {
218
+ select: {
219
+ id: true,
220
+ username: true,
221
+ profile: {
222
+ select: {
223
+ displayName: true,
224
+ profilePicture: true,
225
+ profilePictureThumbnail: true,
226
+ },
227
+ },
228
+ },
229
+ },
230
+ },
231
+ orderBy: { createdAt: "asc" as const },
232
+ },
233
+ } as const;
234
+
175
235
  export const assignmentRouter = createTRPCRouter({
176
236
  reorder: protectedTeacherProcedure
177
237
  .input(z.object({
@@ -254,6 +314,170 @@ export const assignmentRouter = createTRPCRouter({
254
314
  .input(z.object({ assignmentId: z.string(), classId: z.string() }))
255
315
  .query(({ ctx, input }) => getSubmissions(input.assignmentId, ctx.user!.id)),
256
316
 
317
+ getComments: protectedClassMemberProcedure
318
+ .input(assignmentCommentsSchema)
319
+ .query(async ({ input }) => {
320
+ const assignment = await prisma.assignment.findFirst({
321
+ where: { id: input.assignmentId, classId: input.classId },
322
+ select: { id: true },
323
+ });
324
+ if (!assignment) {
325
+ throw new TRPCError({
326
+ code: "NOT_FOUND",
327
+ message: "Assignment not found",
328
+ });
329
+ }
330
+
331
+ const comments = await prisma.comment.findMany({
332
+ where: {
333
+ assignmentId: input.assignmentId,
334
+ submissionId: null,
335
+ parentCommentId: null,
336
+ },
337
+ include: commentInclude,
338
+ orderBy: { createdAt: "asc" },
339
+ });
340
+
341
+ return { comments };
342
+ }),
343
+
344
+ addComment: protectedClassMemberProcedure
345
+ .input(assignmentCommentSchema)
346
+ .mutation(async ({ ctx, input }) => {
347
+ const assignment = await prisma.assignment.findFirst({
348
+ where: { id: input.assignmentId, classId: input.classId },
349
+ select: { id: true },
350
+ });
351
+ if (!assignment) {
352
+ throw new TRPCError({
353
+ code: "NOT_FOUND",
354
+ message: "Assignment not found",
355
+ });
356
+ }
357
+
358
+ if (input.parentCommentId) {
359
+ const parentComment = await prisma.comment.findFirst({
360
+ where: {
361
+ id: input.parentCommentId,
362
+ assignmentId: input.assignmentId,
363
+ submissionId: null,
364
+ },
365
+ select: { id: true },
366
+ });
367
+ if (!parentComment) {
368
+ throw new TRPCError({
369
+ code: "NOT_FOUND",
370
+ message: "Parent comment not found",
371
+ });
372
+ }
373
+ }
374
+
375
+ const comment = await prisma.comment.create({
376
+ data: {
377
+ content: input.content,
378
+ authorId: ctx.user!.id,
379
+ assignmentId: input.assignmentId,
380
+ ...(input.parentCommentId && { parentCommentId: input.parentCommentId }),
381
+ },
382
+ include: commentInclude,
383
+ });
384
+
385
+ return { comment };
386
+ }),
387
+
388
+ getSubmissionComments: protectedClassMemberProcedure
389
+ .input(submissionCommentsSchema)
390
+ .query(async ({ ctx, input }) => {
391
+ const submission = await prisma.submission.findFirst({
392
+ where: {
393
+ id: input.submissionId,
394
+ assignmentId: input.assignmentId,
395
+ assignment: {
396
+ classId: input.classId,
397
+ class: {
398
+ OR: [
399
+ { teachers: { some: { id: ctx.user!.id } } },
400
+ { students: { some: { id: ctx.user!.id } } },
401
+ ],
402
+ },
403
+ },
404
+ OR: [
405
+ { studentId: ctx.user!.id },
406
+ { assignment: { class: { teachers: { some: { id: ctx.user!.id } } } } },
407
+ ],
408
+ },
409
+ select: { id: true },
410
+ });
411
+ if (!submission) {
412
+ throw new TRPCError({
413
+ code: "NOT_FOUND",
414
+ message: "Submission not found",
415
+ });
416
+ }
417
+
418
+ const comments = await prisma.comment.findMany({
419
+ where: {
420
+ submissionId: input.submissionId,
421
+ parentCommentId: null,
422
+ },
423
+ include: commentInclude,
424
+ orderBy: { createdAt: "asc" },
425
+ });
426
+
427
+ return { comments };
428
+ }),
429
+
430
+ addSubmissionComment: protectedClassMemberProcedure
431
+ .input(submissionCommentSchema)
432
+ .mutation(async ({ ctx, input }) => {
433
+ const submission = await prisma.submission.findFirst({
434
+ where: {
435
+ id: input.submissionId,
436
+ assignmentId: input.assignmentId,
437
+ assignment: { classId: input.classId },
438
+ OR: [
439
+ { studentId: ctx.user!.id },
440
+ { assignment: { class: { teachers: { some: { id: ctx.user!.id } } } } },
441
+ ],
442
+ },
443
+ select: { id: true },
444
+ });
445
+ if (!submission) {
446
+ throw new TRPCError({
447
+ code: "NOT_FOUND",
448
+ message: "Submission not found",
449
+ });
450
+ }
451
+
452
+ if (input.parentCommentId) {
453
+ const parentComment = await prisma.comment.findFirst({
454
+ where: {
455
+ id: input.parentCommentId,
456
+ submissionId: input.submissionId,
457
+ },
458
+ select: { id: true },
459
+ });
460
+ if (!parentComment) {
461
+ throw new TRPCError({
462
+ code: "NOT_FOUND",
463
+ message: "Parent comment not found",
464
+ });
465
+ }
466
+ }
467
+
468
+ const comment = await prisma.comment.create({
469
+ data: {
470
+ content: input.content,
471
+ authorId: ctx.user!.id,
472
+ submissionId: input.submissionId,
473
+ ...(input.parentCommentId && { parentCommentId: input.parentCommentId }),
474
+ },
475
+ include: commentInclude,
476
+ });
477
+
478
+ return { comment };
479
+ }),
480
+
257
481
  updateSubmissionAsTeacher: protectedTeacherProcedure
258
482
  .input(updateSubmissionSchema)
259
483
  .mutation(({ ctx, input }) =>
@@ -660,4 +884,3 @@ export const assignmentRouter = createTRPCRouter({
660
884
  };
661
885
  }),
662
886
  });
663
-
@@ -5,6 +5,8 @@ import {
5
5
  getComment,
6
6
  getReplies,
7
7
  replyToComment,
8
+ updateComment,
9
+ deleteComment,
8
10
  addReaction,
9
11
  removeReaction,
10
12
  getReactions,
@@ -15,11 +17,11 @@ export { commentSelect } from "../models/comment.js";
15
17
  export const commentRouter = createTRPCRouter({
16
18
  get: protectedProcedure
17
19
  .input(z.object({ id: z.string() }))
18
- .query(({ input }) => getComment(input.id)),
20
+ .query(({ ctx, input }) => getComment(ctx.user!.id, input.id)),
19
21
 
20
22
  getReplies: protectedProcedure
21
23
  .input(z.object({ commentId: z.string() }))
22
- .query(({ input }) => getReplies(input.commentId)),
24
+ .query(({ ctx, input }) => getReplies(ctx.user!.id, input.commentId)),
23
25
 
24
26
  replyToComment: protectedProcedure
25
27
  .input(
@@ -32,6 +34,21 @@ export const commentRouter = createTRPCRouter({
32
34
  replyToComment(ctx.user!.id, input.parentCommentId, input.content)
33
35
  ),
34
36
 
37
+ update: protectedProcedure
38
+ .input(
39
+ z.object({
40
+ id: z.string(),
41
+ content: z.string().min(1, "Comment cannot be empty"),
42
+ })
43
+ )
44
+ .mutation(({ ctx, input }) =>
45
+ updateComment(ctx.user!.id, input.id, input.content)
46
+ ),
47
+
48
+ delete: protectedProcedure
49
+ .input(z.object({ id: z.string() }))
50
+ .mutation(({ ctx, input }) => deleteComment(ctx.user!.id, input.id)),
51
+
35
52
  addReaction: protectedProcedure
36
53
  .input(
37
54
  z.object({
@@ -5,6 +5,7 @@ import {
5
5
  createConversationRecord,
6
6
  getConversation,
7
7
  addMemberToConversation,
8
+ hideConversation,
8
9
  removeMemberFromConversation,
9
10
  } from "../services/conversation.js";
10
11
 
@@ -36,6 +37,12 @@ export const conversationRouter = createTRPCRouter({
36
37
  getConversation(ctx.user!.id, input.conversationId)
37
38
  ),
38
39
 
40
+ hide: protectedProcedure
41
+ .input(z.object({ conversationId: z.string() }))
42
+ .mutation(({ ctx, input }) =>
43
+ hideConversation(ctx.user!.id, input.conversationId)
44
+ ),
45
+
39
46
  addMember: protectedProcedure
40
47
  .input(
41
48
  z.object({