@studious-lms/server 1.11.0 → 1.12.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 (156) 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 +18 -0
  24. package/dist/models/class.d.ts.map +1 -1
  25. package/dist/models/class.js +5 -2
  26. package/dist/models/class.js.map +1 -1
  27. package/dist/models/comment.d.ts +6 -0
  28. package/dist/models/comment.d.ts.map +1 -1
  29. package/dist/models/event.d.ts +12 -0
  30. package/dist/models/event.d.ts.map +1 -1
  31. package/dist/models/file.d.ts +38 -0
  32. package/dist/models/file.d.ts.map +1 -1
  33. package/dist/models/folder.d.ts +6 -0
  34. package/dist/models/folder.d.ts.map +1 -1
  35. package/dist/models/labChat.d.ts +6 -0
  36. package/dist/models/labChat.d.ts.map +1 -1
  37. package/dist/models/newtonChat.d.ts +3 -0
  38. package/dist/models/newtonChat.d.ts.map +1 -1
  39. package/dist/models/school.d.ts +3 -0
  40. package/dist/models/school.d.ts.map +1 -1
  41. package/dist/models/section.d.ts +3 -0
  42. package/dist/models/section.d.ts.map +1 -1
  43. package/dist/models/worksheet.d.ts +58 -0
  44. package/dist/models/worksheet.d.ts.map +1 -1
  45. package/dist/models/worksheet.js +26 -2
  46. package/dist/models/worksheet.js.map +1 -1
  47. package/dist/routers/_app.d.ts +4055 -1823
  48. package/dist/routers/_app.d.ts.map +1 -1
  49. package/dist/routers/_app.js +6 -2
  50. package/dist/routers/_app.js.map +1 -1
  51. package/dist/routers/admin.d.ts +190 -0
  52. package/dist/routers/admin.d.ts.map +1 -0
  53. package/dist/routers/admin.js +50 -0
  54. package/dist/routers/admin.js.map +1 -0
  55. package/dist/routers/agenda.d.ts +6 -0
  56. package/dist/routers/agenda.d.ts.map +1 -1
  57. package/dist/routers/announcement.d.ts +2 -0
  58. package/dist/routers/announcement.d.ts.map +1 -1
  59. package/dist/routers/assignment.d.ts +50 -0
  60. package/dist/routers/assignment.d.ts.map +1 -1
  61. package/dist/routers/class.d.ts +31 -0
  62. package/dist/routers/class.d.ts.map +1 -1
  63. package/dist/routers/class.js +5 -2
  64. package/dist/routers/class.js.map +1 -1
  65. package/dist/routers/comment.d.ts +2 -0
  66. package/dist/routers/comment.d.ts.map +1 -1
  67. package/dist/routers/discussion.d.ts +823 -0
  68. package/dist/routers/discussion.d.ts.map +1 -0
  69. package/dist/routers/discussion.js +151 -0
  70. package/dist/routers/discussion.js.map +1 -0
  71. package/dist/routers/event.d.ts +3 -0
  72. package/dist/routers/event.d.ts.map +1 -1
  73. package/dist/routers/file.d.ts +4 -0
  74. package/dist/routers/file.d.ts.map +1 -1
  75. package/dist/routers/school.d.ts +6 -0
  76. package/dist/routers/school.d.ts.map +1 -1
  77. package/dist/routers/worksheet.d.ts +3 -0
  78. package/dist/routers/worksheet.d.ts.map +1 -1
  79. package/dist/seedDatabase.d.ts.map +1 -1
  80. package/dist/seedDatabase.js +1490 -1307
  81. package/dist/seedDatabase.js.map +1 -1
  82. package/dist/services/adminInstitution.d.ts +102 -0
  83. package/dist/services/adminInstitution.d.ts.map +1 -0
  84. package/dist/services/adminInstitution.js +117 -0
  85. package/dist/services/adminInstitution.js.map +1 -0
  86. package/dist/services/adminRunJob.d.ts +45 -0
  87. package/dist/services/adminRunJob.d.ts.map +1 -0
  88. package/dist/services/adminRunJob.js +41 -0
  89. package/dist/services/adminRunJob.js.map +1 -0
  90. package/dist/services/agenda.d.ts +6 -0
  91. package/dist/services/agenda.d.ts.map +1 -1
  92. package/dist/services/assignment.d.ts +50 -0
  93. package/dist/services/assignment.d.ts.map +1 -1
  94. package/dist/services/assignment.js +8 -3
  95. package/dist/services/assignment.js.map +1 -1
  96. package/dist/services/class.d.ts +31 -0
  97. package/dist/services/class.d.ts.map +1 -1
  98. package/dist/services/class.js +14 -2
  99. package/dist/services/class.js.map +1 -1
  100. package/dist/services/comment.d.ts +2 -0
  101. package/dist/services/comment.d.ts.map +1 -1
  102. package/dist/services/discussion.d.ts +762 -0
  103. package/dist/services/discussion.d.ts.map +1 -0
  104. package/dist/services/discussion.js +625 -0
  105. package/dist/services/discussion.js.map +1 -0
  106. package/dist/services/event.d.ts +3 -0
  107. package/dist/services/event.d.ts.map +1 -1
  108. package/dist/services/file.d.ts +8 -0
  109. package/dist/services/file.d.ts.map +1 -1
  110. package/dist/services/school.d.ts +6 -0
  111. package/dist/services/school.d.ts.map +1 -1
  112. package/dist/services/studentProgress.d.ts.map +1 -1
  113. package/dist/services/studentProgress.js +27 -5
  114. package/dist/services/studentProgress.js.map +1 -1
  115. package/dist/services/studentProgressInferenceCache.d.ts +16 -0
  116. package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
  117. package/dist/services/studentProgressInferenceCache.js +43 -0
  118. package/dist/services/studentProgressInferenceCache.js.map +1 -0
  119. package/dist/services/worksheet.d.ts +3 -0
  120. package/dist/services/worksheet.d.ts.map +1 -1
  121. package/dist/services/worksheet.js +5 -5
  122. package/dist/services/worksheet.js.map +1 -1
  123. package/dist/trpc.d.ts +12 -0
  124. package/dist/trpc.d.ts.map +1 -1
  125. package/dist/trpc.js +16 -3
  126. package/dist/trpc.js.map +1 -1
  127. package/dist/utils/adminPanelAccess.d.ts +8 -0
  128. package/dist/utils/adminPanelAccess.d.ts.map +1 -0
  129. package/dist/utils/adminPanelAccess.js +44 -0
  130. package/dist/utils/adminPanelAccess.js.map +1 -0
  131. package/package.json +1 -1
  132. package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
  133. package/prisma/schema.prisma +97 -0
  134. package/src/index.ts +39 -2
  135. package/src/lib/config/cors.ts +5 -0
  136. package/src/lib/config/env.ts +3 -0
  137. package/src/lib/pusher.ts +2 -1
  138. package/src/models/class.ts +3 -0
  139. package/src/models/worksheet.ts +28 -0
  140. package/src/routers/_app.ts +4 -0
  141. package/src/routers/admin.ts +70 -0
  142. package/src/routers/class.ts +3 -0
  143. package/src/routers/discussion.ts +205 -0
  144. package/src/seedDatabase.ts +1713 -1361
  145. package/src/services/adminInstitution.ts +140 -0
  146. package/src/services/adminRunJob.ts +46 -0
  147. package/src/services/assignment.ts +11 -1
  148. package/src/services/class.ts +18 -0
  149. package/src/services/discussion.ts +701 -0
  150. package/src/services/studentProgress.ts +80 -3
  151. package/src/services/studentProgressInferenceCache.ts +64 -0
  152. package/src/services/worksheet.ts +4 -2
  153. package/src/trpc.ts +15 -0
  154. package/src/utils/adminPanelAccess.ts +46 -0
  155. package/tests/integration/routers/discussion.test.ts +253 -0
  156. package/tests/unit/services/discussion.test.ts +356 -0
@@ -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)
@@ -507,6 +528,74 @@ model Comment {
507
528
  @@index([submissionId])
508
529
  }
509
530
 
531
+ model Discussion {
532
+ id String @id @default(uuid())
533
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
534
+ classId String
535
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
536
+ authorId String
537
+ title String
538
+ body String
539
+ type DiscussionType @default(DISCUSSION)
540
+ isPinned Boolean @default(false)
541
+ isLocked Boolean @default(false)
542
+ allowReplies Boolean @default(true)
543
+ attachments File[] @relation("DiscussionFiles")
544
+ tags DiscussionTag[] @relation("DiscussionToTag")
545
+ replies DiscussionReply[]
546
+ reactions Reaction[]
547
+ reads DiscussionRead[]
548
+ createdAt DateTime @default(now())
549
+ updatedAt DateTime @updatedAt
550
+ lastActivityAt DateTime @default(now())
551
+
552
+ @@index([classId, isPinned, lastActivityAt])
553
+ @@index([classId, createdAt])
554
+ }
555
+
556
+ model DiscussionReply {
557
+ id String @id @default(uuid())
558
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
559
+ discussionId String
560
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
561
+ authorId String
562
+ parent DiscussionReply? @relation("ReplyTree", fields: [parentId], references: [id], onDelete: Cascade)
563
+ parentId String?
564
+ children DiscussionReply[] @relation("ReplyTree")
565
+ body String
566
+ attachments File[] @relation("DiscussionReplyFiles")
567
+ reactions Reaction[]
568
+ isAcceptedAnswer Boolean @default(false)
569
+ createdAt DateTime @default(now())
570
+ modifiedAt DateTime? @updatedAt
571
+ deletedAt DateTime?
572
+
573
+ @@index([discussionId, createdAt])
574
+ @@index([parentId])
575
+ }
576
+
577
+ model DiscussionTag {
578
+ id String @id @default(uuid())
579
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
580
+ classId String
581
+ name String
582
+ color String @default("#3B82F6")
583
+ discussions Discussion[] @relation("DiscussionToTag")
584
+
585
+ @@unique([classId, name])
586
+ }
587
+
588
+ model DiscussionRead {
589
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
590
+ userId String
591
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
592
+ discussionId String
593
+ lastReadAt DateTime @default(now())
594
+
595
+ @@id([userId, discussionId])
596
+ @@index([userId])
597
+ }
598
+
510
599
  model Reaction {
511
600
  id String @id @default(uuid())
512
601
  type ReactionType
@@ -516,12 +605,20 @@ model Reaction {
516
605
  announcementId String?
517
606
  comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
518
607
  commentId String?
608
+ discussion Discussion? @relation(fields: [discussionId], references: [id], onDelete: Cascade)
609
+ discussionId String?
610
+ discussionReply DiscussionReply? @relation(fields: [discussionReplyId], references: [id], onDelete: Cascade)
611
+ discussionReplyId String?
519
612
  createdAt DateTime @default(now())
520
613
 
521
614
  @@unique([userId, announcementId])
522
615
  @@unique([userId, commentId])
616
+ @@unique([userId, discussionId])
617
+ @@unique([userId, discussionReplyId])
523
618
  @@index([announcementId])
524
619
  @@index([commentId])
620
+ @@index([discussionId])
621
+ @@index([discussionReplyId])
525
622
  }
526
623
 
527
624
  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 };
@@ -180,6 +180,9 @@ const classIncludeForGet = {
180
180
  },
181
181
  },
182
182
  },
183
+ discussionsEnabled: true,
184
+ studentsCanCreateDiscussions: true,
185
+ studentsCanReply: true,
183
186
  } as const;
184
187
 
185
188
  const assignmentSelect = {
@@ -456,6 +456,34 @@ export function findStudentQuestionProgressByIdForTeacherAndResponse(
456
456
  });
457
457
  }
458
458
 
459
+ /**
460
+ * @returns Student question progress if requester is the answering student (class member) or a
461
+ * class teacher / school admin for that worksheet.
462
+ */
463
+ export function findStudentQuestionProgressByIdForStudentOrTeacher(
464
+ id: string,
465
+ userId: string
466
+ ) {
467
+ return prisma.studentQuestionProgress.findFirst({
468
+ where: {
469
+ id,
470
+ OR: [
471
+ {
472
+ studentId: userId,
473
+ question: { worksheet: worksheetClassMemberWhere(userId) },
474
+ },
475
+ {
476
+ question: { worksheet: worksheetTeacherWhere(userId) },
477
+ },
478
+ ],
479
+ },
480
+ include: {
481
+ question: true,
482
+ studentWorksheetResponse: true,
483
+ },
484
+ });
485
+ }
486
+
459
487
  /** Update student's question progress. */
460
488
  export function updateStudentQuestionProgress(
461
489
  id: string,
@@ -26,6 +26,8 @@ import { schoolRouter } from "./school.js";
26
26
  import { departmentRouter } from "./department.js";
27
27
  import { tagRouter } from "./tag.js";
28
28
  import { permissionsRouter } from "./permissions.js";
29
+ import { discussionRouter } from "./discussion.js";
30
+ import { adminRouter } from "./admin.js";
29
31
 
30
32
  export const appRouter = createTRPCRouter({
31
33
  class: classRouter,
@@ -53,6 +55,8 @@ export const appRouter = createTRPCRouter({
53
55
  department: departmentRouter,
54
56
  tag: tagRouter,
55
57
  permissions: permissionsRouter,
58
+ discussion: discussionRouter,
59
+ admin: adminRouter,
56
60
  });
57
61
 
58
62
  // Export type router type definition
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { RunJobStatus, RunJobType } from '@prisma/client';
3
+ import { createTRPCRouter, adminProcedure } from '../trpc.js';
4
+ import {
5
+ getRunJobForAdmin,
6
+ listRunJobsForAdmin,
7
+ } from '../services/adminRunJob.js';
8
+ import {
9
+ listDepartmentsForAdmin,
10
+ listEarlyAccessRequestsForAdmin,
11
+ listSchoolDevelopmentProgramsForAdmin,
12
+ listSchoolsForAdmin,
13
+ } from '../services/adminInstitution.js';
14
+
15
+ export const adminRouter = createTRPCRouter({
16
+ listRunJobs: adminProcedure
17
+ .input(
18
+ z.object({
19
+ page: z.number().int().min(0).default(0),
20
+ pageSize: z.number().int().min(1).max(100).default(25),
21
+ status: z.nativeEnum(RunJobStatus).optional(),
22
+ type: z.nativeEnum(RunJobType).optional(),
23
+ }),
24
+ )
25
+ .query(({ input }) => listRunJobsForAdmin(input)),
26
+
27
+ getRunJob: adminProcedure
28
+ .input(z.object({ id: z.string().uuid() }))
29
+ .query(({ input }) => getRunJobForAdmin(input.id)),
30
+
31
+ listSchools: adminProcedure
32
+ .input(
33
+ z.object({
34
+ page: z.number().int().min(0).default(0),
35
+ pageSize: z.number().int().min(1).max(100).default(25),
36
+ query: z.string().optional(),
37
+ }),
38
+ )
39
+ .query(({ input }) => listSchoolsForAdmin(input)),
40
+
41
+ listDepartments: adminProcedure
42
+ .input(
43
+ z.object({
44
+ page: z.number().int().min(0).default(0),
45
+ pageSize: z.number().int().min(1).max(100).default(25),
46
+ schoolId: z.string().uuid().optional(),
47
+ }),
48
+ )
49
+ .query(({ input }) => listDepartmentsForAdmin(input)),
50
+
51
+ listSchoolDevelopmentPrograms: adminProcedure
52
+ .input(
53
+ z.object({
54
+ page: z.number().int().min(0).default(0),
55
+ pageSize: z.number().int().min(1).max(100).default(25),
56
+ status: z.string().optional(),
57
+ query: z.string().optional(),
58
+ }),
59
+ )
60
+ .query(({ input }) => listSchoolDevelopmentProgramsForAdmin(input)),
61
+
62
+ listEarlyAccessRequests: adminProcedure
63
+ .input(
64
+ z.object({
65
+ page: z.number().int().min(0).default(0),
66
+ pageSize: z.number().int().min(1).max(100).default(25),
67
+ }),
68
+ )
69
+ .query(({ input }) => listEarlyAccessRequestsForAdmin(input)),
70
+ });
@@ -60,6 +60,9 @@ export const classRouter = createTRPCRouter({
60
60
  subject: z.string().optional(),
61
61
  color: z.string().optional(),
62
62
  departmentId: z.string().nullable().optional(),
63
+ discussionsEnabled: z.boolean().optional(),
64
+ studentsCanCreateDiscussions: z.boolean().optional(),
65
+ studentsCanReply: z.boolean().optional(),
63
66
  })
64
67
  )
65
68
  .mutation(({ input }) => {
@@ -0,0 +1,205 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
+ import {
4
+ createDiscussion,
5
+ createDiscussionTag,
6
+ deleteDiscussion,
7
+ deleteDiscussionReply,
8
+ deleteDiscussionTag,
9
+ getDiscussionClassAccess,
10
+ getDiscussion,
11
+ getUnreadCounts,
12
+ listDiscussionTags,
13
+ listDiscussions,
14
+ lockDiscussion,
15
+ markAcceptedAnswer,
16
+ markDiscussionRead,
17
+ pinDiscussion,
18
+ replyToDiscussion,
19
+ toggleDiscussionReaction,
20
+ updateDiscussion,
21
+ updateDiscussionReply,
22
+ updateDiscussionTag,
23
+ } from "../services/discussion.js";
24
+ import { confirmDirectUpload, createDirectUploadFiles } from "../lib/fileUpload.js";
25
+ import { prisma } from "../lib/prisma.js";
26
+ import { TRPCError } from "@trpc/server";
27
+
28
+ const discussionTypeSchema = z.enum(["DISCUSSION", "QUESTION", "ANNOUNCEMENT"]);
29
+ const reactionTypeSchema = z.enum(["THUMBSUP", "CELEBRATE", "CARE", "HEART", "IDEA", "HAPPY"]);
30
+ const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
31
+ const MAX_DISCUSSION_UPLOAD_FILES = 20;
32
+ const MAX_DISCUSSION_FILE_BYTES = 25 * 1024 * 1024;
33
+ const reactionToggleInputSchema = z
34
+ .object({
35
+ discussionId: z.string().optional(),
36
+ discussionReplyId: z.string().optional(),
37
+ type: reactionTypeSchema,
38
+ })
39
+ .refine(
40
+ ({ discussionId, discussionReplyId }) => Boolean(discussionId) !== Boolean(discussionReplyId),
41
+ { message: "must provide exactly one of discussionId or discussionReplyId" }
42
+ );
43
+ const directFileSchema = z.object({
44
+ name: z.string().trim().min(1).max(255),
45
+ type: z.string().trim().min(1).max(127),
46
+ size: z.number().int().nonnegative().max(MAX_DISCUSSION_FILE_BYTES),
47
+ });
48
+
49
+ export const discussionRouter = createTRPCRouter({
50
+ list: protectedProcedure
51
+ .input(
52
+ z.object({
53
+ classId: z.string(),
54
+ tagId: z.string().optional(),
55
+ type: discussionTypeSchema.optional(),
56
+ search: z.string().optional(),
57
+ sort: z.enum(["recent", "activity", "top"]).default("activity"),
58
+ cursor: z.string().optional(),
59
+ limit: z.number().min(1).max(50).default(20),
60
+ })
61
+ )
62
+ .query(({ ctx, input }) => listDiscussions(ctx.user!.id, input)),
63
+
64
+ get: protectedProcedure
65
+ .input(z.object({ discussionId: z.string() }))
66
+ .query(({ ctx, input }) => getDiscussion(ctx.user!.id, input.discussionId)),
67
+
68
+ create: protectedProcedure
69
+ .input(
70
+ z.object({
71
+ classId: z.string(),
72
+ title: z.string().trim().min(1).max(160),
73
+ body: z.string().trim().min(1),
74
+ type: discussionTypeSchema.default("DISCUSSION"),
75
+ tagIds: z.array(z.string()).optional(),
76
+ attachmentIds: z.array(z.string()).optional(),
77
+ })
78
+ )
79
+ .mutation(({ ctx, input }) => createDiscussion(ctx.user!.id, input)),
80
+
81
+ getUploadUrls: protectedProcedure
82
+ .input(
83
+ z.object({
84
+ classId: z.string(),
85
+ files: z.array(directFileSchema).min(1).max(MAX_DISCUSSION_UPLOAD_FILES),
86
+ })
87
+ )
88
+ .mutation(async ({ ctx, input }) => {
89
+ await getDiscussionClassAccess(input.classId, ctx.user!.id);
90
+ return {
91
+ uploadFiles: await createDirectUploadFiles(input.files, ctx.user!.id),
92
+ };
93
+ }),
94
+
95
+ confirmUpload: protectedProcedure
96
+ .input(
97
+ z.object({
98
+ fileId: z.string(),
99
+ uploadSuccess: z.boolean(),
100
+ errorMessage: z.string().optional(),
101
+ })
102
+ )
103
+ .mutation(async ({ ctx, input }) => {
104
+ const file = await prisma.file.findFirst({
105
+ where: {
106
+ id: input.fileId,
107
+ userId: ctx.user!.id,
108
+ discussionId: null,
109
+ discussionReplyId: null,
110
+ },
111
+ select: { id: true },
112
+ });
113
+ if (!file) {
114
+ throw new TRPCError({ code: "NOT_FOUND", message: "File not found or you do not have permission" });
115
+ }
116
+ await confirmDirectUpload(input.fileId, input.uploadSuccess, input.errorMessage);
117
+ return { success: true };
118
+ }),
119
+
120
+ update: protectedProcedure
121
+ .input(
122
+ z.object({
123
+ discussionId: z.string(),
124
+ title: z.string().trim().min(1).max(160).optional(),
125
+ body: z.string().trim().min(1).optional(),
126
+ type: discussionTypeSchema.optional(),
127
+ tagIds: z.array(z.string()).optional(),
128
+ allowReplies: z.boolean().optional(),
129
+ })
130
+ )
131
+ .mutation(({ ctx, input }) => updateDiscussion(ctx.user!.id, input)),
132
+
133
+ delete: protectedProcedure
134
+ .input(z.object({ discussionId: z.string() }))
135
+ .mutation(({ ctx, input }) => deleteDiscussion(ctx.user!.id, input.discussionId)),
136
+
137
+ reply: protectedProcedure
138
+ .input(
139
+ z.object({
140
+ discussionId: z.string(),
141
+ parentReplyId: z.string().optional(),
142
+ body: z.string().trim().min(1),
143
+ attachmentIds: z.array(z.string()).optional(),
144
+ })
145
+ )
146
+ .mutation(({ ctx, input }) => replyToDiscussion(ctx.user!.id, input)),
147
+
148
+ updateReply: protectedProcedure
149
+ .input(
150
+ z.object({
151
+ replyId: z.string(),
152
+ body: z.string().trim().min(1),
153
+ attachmentIds: z.array(z.string()).optional(),
154
+ })
155
+ )
156
+ .mutation(({ ctx, input }) => updateDiscussionReply(ctx.user!.id, input)),
157
+
158
+ deleteReply: protectedProcedure
159
+ .input(z.object({ replyId: z.string() }))
160
+ .mutation(({ ctx, input }) => deleteDiscussionReply(ctx.user!.id, input.replyId)),
161
+
162
+ pin: protectedProcedure
163
+ .input(z.object({ discussionId: z.string(), isPinned: z.boolean() }))
164
+ .mutation(({ ctx, input }) => pinDiscussion(ctx.user!.id, input.discussionId, input.isPinned)),
165
+
166
+ lock: protectedProcedure
167
+ .input(z.object({ discussionId: z.string(), isLocked: z.boolean() }))
168
+ .mutation(({ ctx, input }) => lockDiscussion(ctx.user!.id, input.discussionId, input.isLocked)),
169
+
170
+ markAcceptedAnswer: protectedProcedure
171
+ .input(z.object({ replyId: z.string(), accepted: z.boolean() }))
172
+ .mutation(({ ctx, input }) => markAcceptedAnswer(ctx.user!.id, input.replyId, input.accepted)),
173
+
174
+ markRead: protectedProcedure
175
+ .input(z.object({ discussionId: z.string() }))
176
+ .mutation(({ ctx, input }) => markDiscussionRead(ctx.user!.id, input.discussionId)),
177
+
178
+ getUnreadCounts: protectedProcedure
179
+ .input(z.object({ classIds: z.array(z.string()).max(100) }))
180
+ .query(({ ctx, input }) => getUnreadCounts(ctx.user!.id, input.classIds)),
181
+
182
+ reactions: createTRPCRouter({
183
+ toggle: protectedProcedure
184
+ .input(reactionToggleInputSchema)
185
+ .mutation(({ ctx, input }) => toggleDiscussionReaction(ctx.user!.id, input)),
186
+ }),
187
+
188
+ tags: createTRPCRouter({
189
+ list: protectedProcedure
190
+ .input(z.object({ classId: z.string() }))
191
+ .query(({ ctx, input }) => listDiscussionTags(ctx.user!.id, input.classId)),
192
+
193
+ create: protectedProcedure
194
+ .input(z.object({ classId: z.string(), name: z.string().trim().min(1).max(32), color: hexColorSchema }))
195
+ .mutation(({ ctx, input }) => createDiscussionTag(ctx.user!.id, input)),
196
+
197
+ update: protectedProcedure
198
+ .input(z.object({ tagId: z.string(), name: z.string().trim().min(1).max(32).optional(), color: hexColorSchema.optional() }))
199
+ .mutation(({ ctx, input }) => updateDiscussionTag(ctx.user!.id, input)),
200
+
201
+ delete: protectedProcedure
202
+ .input(z.object({ tagId: z.string() }))
203
+ .mutation(({ ctx, input }) => deleteDiscussionTag(ctx.user!.id, input.tagId)),
204
+ }),
205
+ });