@studious-lms/server 1.11.0 → 1.13.0

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 (188) hide show
  1. package/.env.example +3 -0
  2. package/dist/index.js +40 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/config/cors.d.ts.map +1 -1
  5. package/dist/lib/config/cors.js +7 -2
  6. package/dist/lib/config/cors.js.map +1 -1
  7. package/dist/lib/config/env.d.ts +8 -0
  8. package/dist/lib/config/env.d.ts.map +1 -1
  9. package/dist/lib/config/env.js +5 -2
  10. package/dist/lib/config/env.js.map +1 -1
  11. package/dist/lib/pusher.d.ts +2 -1
  12. package/dist/lib/pusher.d.ts.map +1 -1
  13. package/dist/lib/pusher.js +4 -3
  14. package/dist/lib/pusher.js.map +1 -1
  15. package/dist/models/agenda.d.ts +6 -0
  16. package/dist/models/agenda.d.ts.map +1 -1
  17. package/dist/models/announcement.d.ts +3 -0
  18. package/dist/models/announcement.d.ts.map +1 -1
  19. package/dist/models/assignment.d.ts +61 -0
  20. package/dist/models/assignment.d.ts.map +1 -1
  21. package/dist/models/auth.d.ts +6 -0
  22. package/dist/models/auth.d.ts.map +1 -1
  23. package/dist/models/class.d.ts +28 -0
  24. package/dist/models/class.d.ts.map +1 -1
  25. package/dist/models/class.js +12 -3
  26. package/dist/models/class.js.map +1 -1
  27. package/dist/models/comment.d.ts +13 -0
  28. package/dist/models/comment.d.ts.map +1 -1
  29. package/dist/models/comment.js +3 -2
  30. package/dist/models/comment.js.map +1 -1
  31. package/dist/models/conversation.d.ts +17 -0
  32. package/dist/models/conversation.d.ts.map +1 -1
  33. package/dist/models/conversation.js +13 -2
  34. package/dist/models/conversation.js.map +1 -1
  35. package/dist/models/event.d.ts +12 -0
  36. package/dist/models/event.d.ts.map +1 -1
  37. package/dist/models/file.d.ts +38 -0
  38. package/dist/models/file.d.ts.map +1 -1
  39. package/dist/models/folder.d.ts +6 -0
  40. package/dist/models/folder.d.ts.map +1 -1
  41. package/dist/models/labChat.d.ts +6 -0
  42. package/dist/models/labChat.d.ts.map +1 -1
  43. package/dist/models/newtonChat.d.ts +3 -0
  44. package/dist/models/newtonChat.d.ts.map +1 -1
  45. package/dist/models/school.d.ts +3 -0
  46. package/dist/models/school.d.ts.map +1 -1
  47. package/dist/models/section.d.ts +3 -0
  48. package/dist/models/section.d.ts.map +1 -1
  49. package/dist/models/worksheet.d.ts +62 -0
  50. package/dist/models/worksheet.d.ts.map +1 -1
  51. package/dist/models/worksheet.js +37 -7
  52. package/dist/models/worksheet.js.map +1 -1
  53. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  54. package/dist/pipelines/gradeWorksheet.js +59 -14
  55. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  56. package/dist/routers/_app.d.ts +4641 -2285
  57. package/dist/routers/_app.d.ts.map +1 -1
  58. package/dist/routers/_app.js +6 -2
  59. package/dist/routers/_app.js.map +1 -1
  60. package/dist/routers/admin.d.ts +190 -0
  61. package/dist/routers/admin.d.ts.map +1 -0
  62. package/dist/routers/admin.js +50 -0
  63. package/dist/routers/admin.js.map +1 -0
  64. package/dist/routers/agenda.d.ts +6 -0
  65. package/dist/routers/agenda.d.ts.map +1 -1
  66. package/dist/routers/announcement.d.ts +6 -0
  67. package/dist/routers/announcement.d.ts.map +1 -1
  68. package/dist/routers/announcement.js +6 -5
  69. package/dist/routers/announcement.js.map +1 -1
  70. package/dist/routers/assignment.d.ts +58 -0
  71. package/dist/routers/assignment.d.ts.map +1 -1
  72. package/dist/routers/assignment.js +4 -4
  73. package/dist/routers/assignment.js.map +1 -1
  74. package/dist/routers/class.d.ts +54 -0
  75. package/dist/routers/class.d.ts.map +1 -1
  76. package/dist/routers/class.js +5 -2
  77. package/dist/routers/class.js.map +1 -1
  78. package/dist/routers/comment.d.ts +26 -0
  79. package/dist/routers/comment.d.ts.map +1 -1
  80. package/dist/routers/comment.js +6 -3
  81. package/dist/routers/comment.js.map +1 -1
  82. package/dist/routers/discussion.d.ts +823 -0
  83. package/dist/routers/discussion.d.ts.map +1 -0
  84. package/dist/routers/discussion.js +151 -0
  85. package/dist/routers/discussion.js.map +1 -0
  86. package/dist/routers/event.d.ts +3 -0
  87. package/dist/routers/event.d.ts.map +1 -1
  88. package/dist/routers/file.d.ts +4 -0
  89. package/dist/routers/file.d.ts.map +1 -1
  90. package/dist/routers/school.d.ts +6 -0
  91. package/dist/routers/school.d.ts.map +1 -1
  92. package/dist/routers/worksheet.d.ts +6 -0
  93. package/dist/routers/worksheet.d.ts.map +1 -1
  94. package/dist/routers/worksheet.js +4 -2
  95. package/dist/routers/worksheet.js.map +1 -1
  96. package/dist/seedDatabase.d.ts.map +1 -1
  97. package/dist/seedDatabase.js +1490 -1307
  98. package/dist/seedDatabase.js.map +1 -1
  99. package/dist/services/adminInstitution.d.ts +102 -0
  100. package/dist/services/adminInstitution.d.ts.map +1 -0
  101. package/dist/services/adminInstitution.js +117 -0
  102. package/dist/services/adminInstitution.js.map +1 -0
  103. package/dist/services/adminRunJob.d.ts +45 -0
  104. package/dist/services/adminRunJob.d.ts.map +1 -0
  105. package/dist/services/adminRunJob.js +41 -0
  106. package/dist/services/adminRunJob.js.map +1 -0
  107. package/dist/services/agenda.d.ts +6 -0
  108. package/dist/services/agenda.d.ts.map +1 -1
  109. package/dist/services/assignment.d.ts +50 -0
  110. package/dist/services/assignment.d.ts.map +1 -1
  111. package/dist/services/assignment.js +16 -9
  112. package/dist/services/assignment.js.map +1 -1
  113. package/dist/services/class.d.ts +54 -0
  114. package/dist/services/class.d.ts.map +1 -1
  115. package/dist/services/class.js +20 -3
  116. package/dist/services/class.js.map +1 -1
  117. package/dist/services/comment.d.ts +20 -0
  118. package/dist/services/comment.d.ts.map +1 -1
  119. package/dist/services/comment.js +23 -2
  120. package/dist/services/comment.js.map +1 -1
  121. package/dist/services/conversation.d.ts.map +1 -1
  122. package/dist/services/conversation.js +9 -7
  123. package/dist/services/conversation.js.map +1 -1
  124. package/dist/services/discussion.d.ts +762 -0
  125. package/dist/services/discussion.d.ts.map +1 -0
  126. package/dist/services/discussion.js +625 -0
  127. package/dist/services/discussion.js.map +1 -0
  128. package/dist/services/event.d.ts +3 -0
  129. package/dist/services/event.d.ts.map +1 -1
  130. package/dist/services/file.d.ts +8 -0
  131. package/dist/services/file.d.ts.map +1 -1
  132. package/dist/services/school.d.ts +6 -0
  133. package/dist/services/school.d.ts.map +1 -1
  134. package/dist/services/studentProgress.d.ts.map +1 -1
  135. package/dist/services/studentProgress.js +27 -5
  136. package/dist/services/studentProgress.js.map +1 -1
  137. package/dist/services/studentProgressInferenceCache.d.ts +16 -0
  138. package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
  139. package/dist/services/studentProgressInferenceCache.js +43 -0
  140. package/dist/services/studentProgressInferenceCache.js.map +1 -0
  141. package/dist/services/worksheet.d.ts +6 -0
  142. package/dist/services/worksheet.d.ts.map +1 -1
  143. package/dist/services/worksheet.js +5 -5
  144. package/dist/services/worksheet.js.map +1 -1
  145. package/dist/trpc.d.ts +12 -0
  146. package/dist/trpc.d.ts.map +1 -1
  147. package/dist/trpc.js +16 -3
  148. package/dist/trpc.js.map +1 -1
  149. package/dist/utils/adminPanelAccess.d.ts +8 -0
  150. package/dist/utils/adminPanelAccess.d.ts.map +1 -0
  151. package/dist/utils/adminPanelAccess.js +44 -0
  152. package/dist/utils/adminPanelAccess.js.map +1 -0
  153. package/package.json +1 -1
  154. package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
  155. package/prisma/schema.prisma +98 -0
  156. package/src/index.ts +39 -2
  157. package/src/lib/config/cors.ts +5 -0
  158. package/src/lib/config/env.ts +3 -0
  159. package/src/lib/pusher.ts +2 -1
  160. package/src/models/class.ts +10 -1
  161. package/src/models/comment.ts +1 -0
  162. package/src/models/conversation.ts +12 -0
  163. package/src/models/worksheet.ts +39 -5
  164. package/src/pipelines/gradeWorksheet.ts +66 -15
  165. package/src/routers/_app.ts +4 -0
  166. package/src/routers/admin.ts +70 -0
  167. package/src/routers/announcement.ts +4 -3
  168. package/src/routers/assignment.ts +2 -2
  169. package/src/routers/class.ts +3 -0
  170. package/src/routers/comment.ts +5 -0
  171. package/src/routers/discussion.ts +205 -0
  172. package/src/routers/worksheet.ts +2 -0
  173. package/src/seedDatabase.ts +1713 -1361
  174. package/src/services/adminInstitution.ts +140 -0
  175. package/src/services/adminRunJob.ts +46 -0
  176. package/src/services/assignment.ts +19 -8
  177. package/src/services/class.ts +28 -1
  178. package/src/services/comment.ts +22 -0
  179. package/src/services/conversation.ts +9 -4
  180. package/src/services/discussion.ts +701 -0
  181. package/src/services/studentProgress.ts +80 -3
  182. package/src/services/studentProgressInferenceCache.ts +64 -0
  183. package/src/services/worksheet.ts +5 -2
  184. package/src/trpc.ts +15 -0
  185. package/src/utils/adminPanelAccess.ts +46 -0
  186. package/tests/integration/routers/conversation.test.ts +9 -7
  187. package/tests/integration/routers/discussion.test.ts +253 -0
  188. package/tests/unit/services/discussion.test.ts +356 -0
@@ -0,0 +1,151 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "DiscussionType" AS ENUM ('DISCUSSION', 'QUESTION', 'ANNOUNCEMENT');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "Class" ADD COLUMN "discussionsEnabled" BOOLEAN NOT NULL DEFAULT true;
6
+ ALTER TABLE "Class" ADD COLUMN "studentsCanCreateDiscussions" BOOLEAN NOT NULL DEFAULT true;
7
+ ALTER TABLE "Class" ADD COLUMN "studentsCanReply" BOOLEAN NOT NULL DEFAULT true;
8
+
9
+ -- AlterTable
10
+ ALTER TABLE "File" ADD COLUMN "discussionId" TEXT;
11
+ ALTER TABLE "File" ADD COLUMN "discussionReplyId" TEXT;
12
+
13
+ -- AlterTable
14
+ ALTER TABLE "Reaction" ADD COLUMN "discussionId" TEXT;
15
+ ALTER TABLE "Reaction" ADD COLUMN "discussionReplyId" TEXT;
16
+
17
+ -- CreateTable
18
+ CREATE TABLE "Discussion" (
19
+ "id" TEXT NOT NULL,
20
+ "classId" TEXT NOT NULL,
21
+ "authorId" TEXT NOT NULL,
22
+ "title" TEXT NOT NULL,
23
+ "body" TEXT NOT NULL,
24
+ "type" "DiscussionType" NOT NULL DEFAULT 'DISCUSSION',
25
+ "isPinned" BOOLEAN NOT NULL DEFAULT false,
26
+ "isLocked" BOOLEAN NOT NULL DEFAULT false,
27
+ "allowReplies" BOOLEAN NOT NULL DEFAULT true,
28
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ "updatedAt" TIMESTAMP(3) NOT NULL,
30
+ "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
31
+
32
+ CONSTRAINT "Discussion_pkey" PRIMARY KEY ("id")
33
+ );
34
+
35
+ -- CreateTable
36
+ CREATE TABLE "DiscussionReply" (
37
+ "id" TEXT NOT NULL,
38
+ "discussionId" TEXT NOT NULL,
39
+ "authorId" TEXT NOT NULL,
40
+ "parentId" TEXT,
41
+ "body" TEXT NOT NULL,
42
+ "isAcceptedAnswer" BOOLEAN NOT NULL DEFAULT false,
43
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
44
+ "modifiedAt" TIMESTAMP(3),
45
+ "deletedAt" TIMESTAMP(3),
46
+
47
+ CONSTRAINT "DiscussionReply_pkey" PRIMARY KEY ("id")
48
+ );
49
+
50
+ -- CreateTable
51
+ CREATE TABLE "DiscussionTag" (
52
+ "id" TEXT NOT NULL,
53
+ "classId" TEXT NOT NULL,
54
+ "name" TEXT NOT NULL,
55
+ "color" TEXT NOT NULL DEFAULT '#3B82F6',
56
+
57
+ CONSTRAINT "DiscussionTag_pkey" PRIMARY KEY ("id")
58
+ );
59
+
60
+ -- CreateTable
61
+ CREATE TABLE "DiscussionRead" (
62
+ "userId" TEXT NOT NULL,
63
+ "discussionId" TEXT NOT NULL,
64
+ "lastReadAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
65
+
66
+ CONSTRAINT "DiscussionRead_pkey" PRIMARY KEY ("userId","discussionId")
67
+ );
68
+
69
+ -- CreateTable
70
+ CREATE TABLE "_DiscussionToTag" (
71
+ "A" TEXT NOT NULL,
72
+ "B" TEXT NOT NULL
73
+ );
74
+
75
+ -- CreateIndex
76
+ CREATE INDEX "Discussion_classId_isPinned_lastActivityAt_idx" ON "Discussion"("classId", "isPinned", "lastActivityAt");
77
+
78
+ -- CreateIndex
79
+ CREATE INDEX "Discussion_classId_createdAt_idx" ON "Discussion"("classId", "createdAt");
80
+
81
+ -- CreateIndex
82
+ CREATE INDEX "DiscussionReply_discussionId_createdAt_idx" ON "DiscussionReply"("discussionId", "createdAt");
83
+
84
+ -- CreateIndex
85
+ CREATE INDEX "DiscussionReply_parentId_idx" ON "DiscussionReply"("parentId");
86
+
87
+ -- CreateIndex
88
+ CREATE UNIQUE INDEX "DiscussionTag_classId_name_key" ON "DiscussionTag"("classId", "name");
89
+
90
+ -- CreateIndex
91
+ CREATE INDEX "DiscussionRead_userId_idx" ON "DiscussionRead"("userId");
92
+
93
+ -- CreateIndex
94
+ CREATE UNIQUE INDEX "_DiscussionToTag_AB_unique" ON "_DiscussionToTag"("A", "B");
95
+
96
+ -- CreateIndex
97
+ CREATE INDEX "_DiscussionToTag_B_index" ON "_DiscussionToTag"("B");
98
+
99
+ -- CreateIndex
100
+ CREATE UNIQUE INDEX "Reaction_userId_discussionId_key" ON "Reaction"("userId", "discussionId");
101
+
102
+ -- CreateIndex
103
+ CREATE UNIQUE INDEX "Reaction_userId_discussionReplyId_key" ON "Reaction"("userId", "discussionReplyId");
104
+
105
+ -- CreateIndex
106
+ CREATE INDEX "Reaction_discussionId_idx" ON "Reaction"("discussionId");
107
+
108
+ -- CreateIndex
109
+ CREATE INDEX "Reaction_discussionReplyId_idx" ON "Reaction"("discussionReplyId");
110
+
111
+ -- AddForeignKey
112
+ ALTER TABLE "File" ADD CONSTRAINT "File_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
113
+
114
+ -- AddForeignKey
115
+ ALTER TABLE "File" ADD CONSTRAINT "File_discussionReplyId_fkey" FOREIGN KEY ("discussionReplyId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
116
+
117
+ -- AddForeignKey
118
+ ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
119
+
120
+ -- AddForeignKey
121
+ ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
122
+
123
+ -- AddForeignKey
124
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
125
+
126
+ -- AddForeignKey
127
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
128
+
129
+ -- AddForeignKey
130
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
131
+
132
+ -- AddForeignKey
133
+ ALTER TABLE "DiscussionTag" ADD CONSTRAINT "DiscussionTag_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
134
+
135
+ -- AddForeignKey
136
+ ALTER TABLE "DiscussionRead" ADD CONSTRAINT "DiscussionRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
137
+
138
+ -- AddForeignKey
139
+ ALTER TABLE "DiscussionRead" ADD CONSTRAINT "DiscussionRead_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
140
+
141
+ -- AddForeignKey
142
+ ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
143
+
144
+ -- AddForeignKey
145
+ ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_discussionReplyId_fkey" FOREIGN KEY ("discussionReplyId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
146
+
147
+ -- AddForeignKey
148
+ ALTER TABLE "_DiscussionToTag" ADD CONSTRAINT "_DiscussionToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
149
+
150
+ -- AddForeignKey
151
+ ALTER TABLE "_DiscussionToTag" ADD CONSTRAINT "_DiscussionToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "DiscussionTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -84,6 +84,12 @@ enum ReactionType {
84
84
  HAPPY
85
85
  }
86
86
 
87
+ enum DiscussionType {
88
+ DISCUSSION
89
+ QUESTION
90
+ ANNOUNCEMENT
91
+ }
92
+
87
93
  enum SubmissionRecommendationState {
88
94
  NONE
89
95
  OPEN
@@ -227,6 +233,9 @@ model User {
227
233
  createdLabChats LabChat[] @relation("CreatedLabChats")
228
234
  comments Comment[]
229
235
  reactions Reaction[]
236
+ discussions Discussion[]
237
+ discussionReplies DiscussionReply[]
238
+ discussionReads DiscussionRead[]
230
239
  }
231
240
 
232
241
  model SchoolMembership {
@@ -311,6 +320,12 @@ model Class {
311
320
  departmentId String?
312
321
  tags Tag[] @relation("ClassTags")
313
322
  permissions Permission[] @relation("ClassPermissions")
323
+ discussions Discussion[]
324
+ discussionTags DiscussionTag[]
325
+
326
+ discussionsEnabled Boolean @default(true)
327
+ studentsCanCreateDiscussions Boolean @default(true)
328
+ studentsCanReply Boolean @default(true)
314
329
 
315
330
  @@index([schoolId])
316
331
  @@index([departmentId])
@@ -422,6 +437,12 @@ model File {
422
437
  announcement Announcement? @relation("AnnouncementAttachments", fields: [announcementId], references: [id], onDelete: Cascade)
423
438
  announcementId String?
424
439
 
440
+ discussion Discussion? @relation("DiscussionFiles", fields: [discussionId], references: [id], onDelete: Cascade)
441
+ discussionId String?
442
+
443
+ discussionReply DiscussionReply? @relation("DiscussionReplyFiles", fields: [discussionReplyId], references: [id], onDelete: Cascade)
444
+ discussionReplyId String?
445
+
425
446
  schools School[] @relation("SchoolLogo")
426
447
 
427
448
  schoolDevelopementProgram SchoolDevelopementProgram? @relation("SchoolDevelopementProgramSupportingDocumentation", fields: [schoolDevelopementProgramId], references: [id], onDelete: Cascade)
@@ -498,6 +519,7 @@ model Comment {
498
519
  parentCommentId String?
499
520
  replies Comment[] @relation("CommentReplies")
500
521
  reactions Reaction[]
522
+ isPinned Boolean @default(false)
501
523
  createdAt DateTime @default(now())
502
524
  modifiedAt DateTime? @updatedAt
503
525
  studentQuestionProgress StudentQuestionProgress? @relation("StudentQuestionProgressComments", fields: [studentQuestionProgressId], references: [id], onDelete: Cascade)
@@ -507,6 +529,74 @@ model Comment {
507
529
  @@index([submissionId])
508
530
  }
509
531
 
532
+ model Discussion {
533
+ id String @id @default(uuid())
534
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
535
+ classId String
536
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
537
+ authorId String
538
+ title String
539
+ body String
540
+ type DiscussionType @default(DISCUSSION)
541
+ isPinned Boolean @default(false)
542
+ isLocked Boolean @default(false)
543
+ allowReplies Boolean @default(true)
544
+ attachments File[] @relation("DiscussionFiles")
545
+ tags DiscussionTag[] @relation("DiscussionToTag")
546
+ replies DiscussionReply[]
547
+ reactions Reaction[]
548
+ reads DiscussionRead[]
549
+ createdAt DateTime @default(now())
550
+ updatedAt DateTime @updatedAt
551
+ lastActivityAt DateTime @default(now())
552
+
553
+ @@index([classId, isPinned, lastActivityAt])
554
+ @@index([classId, createdAt])
555
+ }
556
+
557
+ model DiscussionReply {
558
+ id String @id @default(uuid())
559
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
560
+ discussionId String
561
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
562
+ authorId String
563
+ parent DiscussionReply? @relation("ReplyTree", fields: [parentId], references: [id], onDelete: Cascade)
564
+ parentId String?
565
+ children DiscussionReply[] @relation("ReplyTree")
566
+ body String
567
+ attachments File[] @relation("DiscussionReplyFiles")
568
+ reactions Reaction[]
569
+ isAcceptedAnswer Boolean @default(false)
570
+ createdAt DateTime @default(now())
571
+ modifiedAt DateTime? @updatedAt
572
+ deletedAt DateTime?
573
+
574
+ @@index([discussionId, createdAt])
575
+ @@index([parentId])
576
+ }
577
+
578
+ model DiscussionTag {
579
+ id String @id @default(uuid())
580
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
581
+ classId String
582
+ name String
583
+ color String @default("#3B82F6")
584
+ discussions Discussion[] @relation("DiscussionToTag")
585
+
586
+ @@unique([classId, name])
587
+ }
588
+
589
+ model DiscussionRead {
590
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
591
+ userId String
592
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
593
+ discussionId String
594
+ lastReadAt DateTime @default(now())
595
+
596
+ @@id([userId, discussionId])
597
+ @@index([userId])
598
+ }
599
+
510
600
  model Reaction {
511
601
  id String @id @default(uuid())
512
602
  type ReactionType
@@ -516,12 +606,20 @@ model Reaction {
516
606
  announcementId String?
517
607
  comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
518
608
  commentId String?
609
+ discussion Discussion? @relation(fields: [discussionId], references: [id], onDelete: Cascade)
610
+ discussionId String?
611
+ discussionReply DiscussionReply? @relation(fields: [discussionReplyId], references: [id], onDelete: Cascade)
612
+ discussionReplyId String?
519
613
  createdAt DateTime @default(now())
520
614
 
521
615
  @@unique([userId, announcementId])
522
616
  @@unique([userId, commentId])
617
+ @@unique([userId, discussionId])
618
+ @@unique([userId, discussionReplyId])
523
619
  @@index([announcementId])
524
620
  @@index([commentId])
621
+ @@index([discussionId])
622
+ @@index([discussionReplyId])
525
623
  }
526
624
 
527
625
  model Submission {
package/src/index.ts CHANGED
@@ -58,7 +58,8 @@ app.use(cors({
58
58
  },
59
59
  credentials: true,
60
60
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
61
- allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
61
+ // Omit allowedHeaders so `cors` echoes `Access-Control-Request-Headers` on preflight.
62
+ // A fixed list breaks browsers that add headers (e.g. Sentry `baggage`, `sentry-trace`).
62
63
  preflightContinue: false,
63
64
  optionsSuccessStatus: 204,
64
65
  }));
@@ -221,6 +222,38 @@ app.post('/api/pusher/auth', async (req, res) => {
221
222
  }
222
223
  }
223
224
 
225
+ if (channel_name.startsWith('private-class-') && channel_name.endsWith('-discussions')) {
226
+ const classId = channel_name
227
+ .replace('private-class-', '')
228
+ .replace('-discussions', '');
229
+ const isMember = await prisma.class.findFirst({
230
+ where: {
231
+ id: classId,
232
+ discussionsEnabled: true,
233
+ OR: [
234
+ { students: { some: { id: user.id } } },
235
+ { teachers: { some: { id: user.id } } },
236
+ {
237
+ school: {
238
+ is: {
239
+ memberships: {
240
+ some: {
241
+ userId: user.id,
242
+ role: 'ADMIN',
243
+ status: 'ACTIVE',
244
+ },
245
+ },
246
+ },
247
+ },
248
+ },
249
+ ],
250
+ },
251
+ });
252
+ if (!isMember) {
253
+ return res.status(403).json({ error: 'Not a member of this class discussion channel' });
254
+ }
255
+ }
256
+
224
257
  // Verify channel access for private-class-* channels
225
258
  // if (channel_name.startsWith('private-class-')) {
226
259
  // const classId = channel_name.replace('private-class-', '');
@@ -586,7 +619,11 @@ app.options('/trpc/*', (req, res) => {
586
619
  res.setHeader('Access-Control-Allow-Origin', origin);
587
620
  res.setHeader('Access-Control-Allow-Credentials', 'true');
588
621
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
589
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
622
+ const requestedHeaders = req.headers['access-control-request-headers'];
623
+ if (requestedHeaders) {
624
+ res.setHeader('Access-Control-Allow-Headers', requestedHeaders);
625
+ res.setHeader('Vary', 'Origin, Access-Control-Request-Headers');
626
+ }
590
627
  }
591
628
  res.sendStatus(204);
592
629
  });
@@ -9,6 +9,11 @@ const productionOrigins = [
9
9
  'https://www.studious.sh',
10
10
  'https://studious.sh',
11
11
  'https://dev.studious.sh',
12
+ // @todo: remove in actual prod
13
+ 'http://localhost:3000',
14
+ 'http://localhost:3001',
15
+ 'http://127.0.0.1:3000',
16
+ 'http://127.0.0.1:3001',
12
17
  ];
13
18
 
14
19
  const nonProductionOrigins = [
@@ -63,6 +63,8 @@ const fullSchema = baseSchema.extend({
63
63
  INFERENCE_API_BASE_URL: z.string().url().optional(),
64
64
  OPENAI_API_KEY: z.string().optional(),
65
65
  LOG_MODE: z.enum(['normal', 'verbose', 'quiet']).default('normal'),
66
+ /** Comma/whitespace-separated user UUIDs allowed to call `admin.*` tRPC (required in production). */
67
+ ADMIN_PANEL_USER_IDS: z.string().optional(),
66
68
  });
67
69
 
68
70
  // Test schema - only require what's needed for tests
@@ -91,6 +93,7 @@ const testSchema = baseSchema.extend({
91
93
  OPENAI_API_KEY: z.string().optional(),
92
94
  INFERENCE_API_BASE_URL: z.string().url().optional(),
93
95
  LOG_MODE: z.enum(['normal', 'verbose', 'quiet']).default('quiet'),
96
+ ADMIN_PANEL_USER_IDS: z.string().optional(),
94
97
  });
95
98
 
96
99
  // Use test schema in test mode, full schema otherwise
package/src/lib/pusher.ts CHANGED
@@ -12,6 +12,7 @@ const pusher = new Pusher({
12
12
  const chatChannel = (conversationId: string) => `private-conversation-${conversationId}`;
13
13
  const worksheetChannel = (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`;
14
14
  const teacherChannel = (classId: string) => `private-teacher-${classId}`;
15
+ const discussionChannel = (classId: string) => `private-class-${classId}-discussions`;
15
16
  /**
16
17
  * Per-worksheet job channel used for asynchronous worksheet creation
17
18
  * (e.g. PDF → worksheet conversion). Distinct prefix from the student
@@ -19,4 +20,4 @@ const teacherChannel = (classId: string) => `private-teacher-${classId}`;
19
20
  */
20
21
  const worksheetJobChannel = (worksheetId: string) => `private-worksheet-job-${worksheetId}`;
21
22
 
22
- export { pusher, chatChannel, worksheetChannel, teacherChannel, worksheetJobChannel };
23
+ export { pusher, chatChannel, worksheetChannel, teacherChannel, discussionChannel, worksheetJobChannel };
@@ -18,7 +18,6 @@ import {
18
18
  const classIncludeForGetAll = {
19
19
  assignments: {
20
20
  where: {
21
- dueDate: { lte: new Date(new Date().setHours(23, 59, 59, 999)) },
22
21
  template: false,
23
22
  },
24
23
  select: {
@@ -26,6 +25,13 @@ const classIncludeForGetAll = {
26
25
  title: true,
27
26
  type: true,
28
27
  dueDate: true,
28
+ submissions: {
29
+ select: {
30
+ studentId: true,
31
+ submitted: true,
32
+ returned: true,
33
+ },
34
+ },
29
35
  },
30
36
  },
31
37
  students: {
@@ -180,6 +186,9 @@ const classIncludeForGet = {
180
186
  },
181
187
  },
182
188
  },
189
+ discussionsEnabled: true,
190
+ studentsCanCreateDiscussions: true,
191
+ studentsCanReply: true,
183
192
  } as const;
184
193
 
185
194
  const assignmentSelect = {
@@ -11,6 +11,7 @@ export const commentSelect = {
11
11
  assignmentId: true,
12
12
  submissionId: true,
13
13
  parentCommentId: true,
14
+ isPinned: true,
14
15
  createdAt: true,
15
16
  modifiedAt: true,
16
17
  author: {
@@ -256,6 +256,18 @@ export function hideConversationMember(userId: string, conversationId: string) {
256
256
  });
257
257
  }
258
258
 
259
+ /** Unhide a conversation for a single member. */
260
+ export function unhideConversationMember(userId: string, conversationId: string) {
261
+ return prisma.conversationMember.update({
262
+ where: {
263
+ userId_conversationId: { userId, conversationId },
264
+ },
265
+ data: {
266
+ hiddenAt: null,
267
+ },
268
+ });
269
+ }
270
+
259
271
  /** Remove a member from a conversation. */
260
272
  export function deleteConversationMember(userId: string, conversationId: string) {
261
273
  return prisma.conversationMember.delete({
@@ -22,7 +22,7 @@ export function findWorksheetById(id: string) {
22
22
  return prisma.worksheet.findUnique({
23
23
  where: { id },
24
24
  include: {
25
- questions: { orderBy: { createdAt: "asc" as const } },
25
+ questions: { orderBy: { order: "asc" as const } },
26
26
  class: true,
27
27
  },
28
28
  });
@@ -43,7 +43,7 @@ export function findWorksheetByIdForClassMember(id: string, userId: string) {
43
43
  ...worksheetClassMemberWhere(userId),
44
44
  },
45
45
  include: {
46
- questions: { orderBy: { createdAt: "asc" as const } },
46
+ questions: { orderBy: { order: "asc" as const } },
47
47
  class: true,
48
48
  },
49
49
  });
@@ -57,7 +57,7 @@ export function findWorksheetByIdForTeacher(id: string, userId: string) {
57
57
  ...worksheetTeacherWhere(userId),
58
58
  },
59
59
  include: {
60
- questions: { orderBy: { createdAt: "asc" as const } },
60
+ questions: { orderBy: { order: "asc" as const } },
61
61
  class: true,
62
62
  },
63
63
  });
@@ -273,7 +273,10 @@ export function findSubmissionByIdForWorksheetAccess(
273
273
  const worksheetResponseInclude = {
274
274
  responses: {
275
275
  include: {
276
- comments: { select: { id: true } },
276
+ comments: {
277
+ select: { id: true, isPinned: true },
278
+ orderBy: [{ isPinned: "desc" as const }, { createdAt: "asc" as const }],
279
+ },
277
280
  },
278
281
  },
279
282
  };
@@ -319,7 +322,10 @@ export function createWorksheetResponse(data: {
319
322
  include: {
320
323
  responses: {
321
324
  include: {
322
- comments: { select: { id: true } },
325
+ comments: {
326
+ select: { id: true, isPinned: true },
327
+ orderBy: [{ isPinned: "desc" as const }, { createdAt: "asc" as const }],
328
+ },
323
329
  },
324
330
  },
325
331
  },
@@ -456,6 +462,34 @@ export function findStudentQuestionProgressByIdForTeacherAndResponse(
456
462
  });
457
463
  }
458
464
 
465
+ /**
466
+ * @returns Student question progress if requester is the answering student (class member) or a
467
+ * class teacher / school admin for that worksheet.
468
+ */
469
+ export function findStudentQuestionProgressByIdForStudentOrTeacher(
470
+ id: string,
471
+ userId: string
472
+ ) {
473
+ return prisma.studentQuestionProgress.findFirst({
474
+ where: {
475
+ id,
476
+ OR: [
477
+ {
478
+ studentId: userId,
479
+ question: { worksheet: worksheetClassMemberWhere(userId) },
480
+ },
481
+ {
482
+ question: { worksheet: worksheetTeacherWhere(userId) },
483
+ },
484
+ ],
485
+ },
486
+ include: {
487
+ question: true,
488
+ studentWorksheetResponse: true,
489
+ },
490
+ });
491
+ }
492
+
459
493
  /** Update student's question progress. */
460
494
  export function updateStudentQuestionProgress(
461
495
  id: string,
@@ -22,6 +22,8 @@ const removeAllPreviousAIComments = async (worksheetQuestionProgressId: string)
22
22
 
23
23
  const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
24
24
 
25
+ await removeAllPreviousAIComments(worksheetQuestionProgressId);
26
+
25
27
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
26
28
  where: { id: worksheetResponseId },
27
29
  include: {
@@ -45,17 +47,8 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
45
47
  });
46
48
 
47
49
  if (!studentQuestionProgress) {
48
- const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.create({
49
- data: {
50
- studentId: worksheetResponse.studentId,
51
- questionId: worksheetQuestionProgressId,
52
- response: '',
53
- isCorrect: false,
54
- markschemeState: {},
55
- },
56
- });
57
-
58
- return updatedStudentQuestionProgress;
50
+ logger.error('Student question progress not found', { worksheetQuestionProgressId });
51
+ throw new Error('Student question progress not found');
59
52
  }
60
53
 
61
54
  pusher.trigger(worksheetChannel(worksheetResponse.id), `set-pending`, {
@@ -63,9 +56,34 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
63
56
  });
64
57
 
65
58
  const question = studentQuestionProgress.question;
66
- const comments = studentQuestionProgress.comments;
67
59
  const responseText = studentQuestionProgress.response;
68
60
 
61
+ // Short-circuit for empty responses: award 0 points without calling inference
62
+ if (!responseText || responseText.trim().length === 0) {
63
+ const updatedProgress = await prisma.studentQuestionProgress.update({
64
+ where: { id: studentQuestionProgress.id, status: {
65
+ not: { in: [JobStatus.CANCELLED] },
66
+ } },
67
+ data: {
68
+ status: JobStatus.COMPLETED,
69
+ isCorrect: false,
70
+ points: 0,
71
+ markschemeState: {},
72
+ comments: {
73
+ create: [{
74
+ content: 'No answer provided.',
75
+ authorId: getAIUserId(),
76
+ }],
77
+ },
78
+ },
79
+ });
80
+ pusher.trigger(worksheetChannel(worksheetResponse.id), `set-completed`, {
81
+ id: updatedProgress.id,
82
+ });
83
+ return updatedProgress;
84
+ }
85
+
86
+ const comments = studentQuestionProgress.comments;
69
87
 
70
88
  try {
71
89
  const apiResponse = await inference(
@@ -206,7 +224,42 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
206
224
  throw new Error('Worksheet response not found');
207
225
  }
208
226
 
209
- // Use for...of instead of forEach to properly handle async operations
227
+ // Find worksheet questions that have no StudentQuestionProgress for this response
228
+ // and create empty progress records so they get graded (with 0 points).
229
+ const allQuestions = await prisma.worksheetQuestion.findMany({
230
+ where: {
231
+ worksheetId: worksheetResponse.worksheetId,
232
+ type: {
233
+ notIn: [WorksheetQuestionType.MULTIPLE_CHOICE, WorksheetQuestionType.TRUE_FALSE],
234
+ },
235
+ },
236
+ select: { id: true },
237
+ });
238
+
239
+ const existingQuestionIds = new Set(
240
+ worksheetResponse.responses.map((r) => r.questionId),
241
+ );
242
+
243
+ for (const q of allQuestions) {
244
+ if (existingQuestionIds.has(q.id)) continue;
245
+
246
+ const created = await prisma.studentQuestionProgress.create({
247
+ data: {
248
+ studentId: worksheetResponse.studentId,
249
+ questionId: q.id,
250
+ studentWorksheetResponseId: worksheetResponseId,
251
+ response: '',
252
+ isCorrect: false,
253
+ },
254
+ });
255
+
256
+ worksheetResponse.responses.push({
257
+ ...created,
258
+ question: { id: q.id } as any,
259
+ comments: [],
260
+ } as any);
261
+ }
262
+
210
263
  for (const response of worksheetResponse.responses) {
211
264
  logger.info('Grading question', { questionId: response.questionId });
212
265
 
@@ -293,8 +346,6 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
293
346
  where: { id: worksheetQuestionProgressId },
294
347
  });
295
348
 
296
- await removeAllPreviousAIComments(worksheetQuestionProgressId);
297
-
298
349
  await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
299
350
 
300
351
  return updatedStudentQuestionProgress;