@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.
- package/.env.example +3 -0
- package/dist/index.js +40 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +7 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +8 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +5 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/pusher.d.ts +2 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +4 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +61 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +6 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +18 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +5 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +6 -0
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/event.d.ts +12 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +38 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +6 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +6 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +3 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +3 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +58 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +26 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +4055 -1823
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +6 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +190 -0
- package/dist/routers/admin.d.ts.map +1 -0
- package/dist/routers/admin.js +50 -0
- package/dist/routers/admin.js.map +1 -0
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +2 -0
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +50 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts +31 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +5 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +2 -0
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/discussion.d.ts +823 -0
- package/dist/routers/discussion.d.ts.map +1 -0
- package/dist/routers/discussion.js +151 -0
- package/dist/routers/discussion.js.map +1 -0
- package/dist/routers/event.d.ts +3 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +4 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +3 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +1490 -1307
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +102 -0
- package/dist/services/adminInstitution.d.ts.map +1 -0
- package/dist/services/adminInstitution.js +117 -0
- package/dist/services/adminInstitution.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +45 -0
- package/dist/services/adminRunJob.d.ts.map +1 -0
- package/dist/services/adminRunJob.js +41 -0
- package/dist/services/adminRunJob.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +50 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +8 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/class.d.ts +31 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +14 -2
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +2 -0
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/discussion.d.ts +762 -0
- package/dist/services/discussion.d.ts.map +1 -0
- package/dist/services/discussion.js +625 -0
- package/dist/services/discussion.js.map +1 -0
- package/dist/services/event.d.ts +3 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +8 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +27 -5
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/studentProgressInferenceCache.d.ts +16 -0
- package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
- package/dist/services/studentProgressInferenceCache.js +43 -0
- package/dist/services/studentProgressInferenceCache.js.map +1 -0
- package/dist/services/worksheet.d.ts +3 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +5 -5
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +12 -0
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +16 -3
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +8 -0
- package/dist/utils/adminPanelAccess.d.ts.map +1 -0
- package/dist/utils/adminPanelAccess.js +44 -0
- package/dist/utils/adminPanelAccess.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
- package/prisma/schema.prisma +97 -0
- package/src/index.ts +39 -2
- package/src/lib/config/cors.ts +5 -0
- package/src/lib/config/env.ts +3 -0
- package/src/lib/pusher.ts +2 -1
- package/src/models/class.ts +3 -0
- package/src/models/worksheet.ts +28 -0
- package/src/routers/_app.ts +4 -0
- package/src/routers/admin.ts +70 -0
- package/src/routers/class.ts +3 -0
- package/src/routers/discussion.ts +205 -0
- package/src/seedDatabase.ts +1713 -1361
- package/src/services/adminInstitution.ts +140 -0
- package/src/services/adminRunJob.ts +46 -0
- package/src/services/assignment.ts +11 -1
- package/src/services/class.ts +18 -0
- package/src/services/discussion.ts +701 -0
- package/src/services/studentProgress.ts +80 -3
- package/src/services/studentProgressInferenceCache.ts +64 -0
- package/src/services/worksheet.ts +4 -2
- package/src/trpc.ts +15 -0
- package/src/utils/adminPanelAccess.ts +46 -0
- package/tests/integration/routers/discussion.test.ts +253 -0
- package/tests/unit/services/discussion.test.ts +356 -0
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|
package/src/lib/config/cors.ts
CHANGED
|
@@ -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 = [
|
package/src/lib/config/env.ts
CHANGED
|
@@ -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 };
|
package/src/models/class.ts
CHANGED
package/src/models/worksheet.ts
CHANGED
|
@@ -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,
|
package/src/routers/_app.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/routers/class.ts
CHANGED
|
@@ -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
|
+
});
|