@studious-lms/server 1.1.11 → 1.1.13

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.
@@ -0,0 +1,824 @@
1
+ import { z } from 'zod';
2
+ import { createTRPCRouter, protectedProcedure } from '../trpc.js';
3
+ import { prisma } from '../lib/prisma.js';
4
+ import { pusher } from '../lib/pusher.js';
5
+ import { TRPCError } from '@trpc/server';
6
+ import {
7
+ inferenceClient,
8
+ sendAIMessage,
9
+ type LabChatContext
10
+ } from '../utils/inference.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { isAIUser } from '../utils/aiUser.js';
13
+ import OpenAI from 'openai';
14
+
15
+ export const labChatRouter = createTRPCRouter({
16
+ create: protectedProcedure
17
+ .input(
18
+ z.object({
19
+ classId: z.string(),
20
+ title: z.string().min(1).max(200),
21
+ context: z.string(), // JSON string for LLM context
22
+ })
23
+ )
24
+ .mutation(async ({ input, ctx }) => {
25
+ const userId = ctx.user!.id;
26
+ const { classId, title, context } = input;
27
+
28
+ // Verify user is a teacher in the class
29
+ const classWithTeachers = await prisma.class.findFirst({
30
+ where: {
31
+ id: classId,
32
+ teachers: {
33
+ some: {
34
+ id: userId,
35
+ },
36
+ },
37
+ },
38
+ include: {
39
+ students: {
40
+ select: {
41
+ id: true,
42
+ username: true,
43
+ profile: {
44
+ select: {
45
+ displayName: true,
46
+ profilePicture: true,
47
+ },
48
+ },
49
+ },
50
+ },
51
+ teachers: {
52
+ select: {
53
+ id: true,
54
+ username: true,
55
+ profile: {
56
+ select: {
57
+ displayName: true,
58
+ profilePicture: true,
59
+ },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ });
65
+
66
+ if (!classWithTeachers) {
67
+ throw new TRPCError({
68
+ code: 'FORBIDDEN',
69
+ message: 'Not a teacher in this class',
70
+ });
71
+ }
72
+
73
+ // Validate context is valid JSON
74
+ try {
75
+ JSON.parse(context);
76
+ } catch (error) {
77
+ throw new TRPCError({
78
+ code: 'BAD_REQUEST',
79
+ message: 'Context must be valid JSON',
80
+ });
81
+ }
82
+
83
+ // Create lab chat with associated conversation
84
+ const result = await prisma.$transaction(async (tx) => {
85
+ // Create conversation for the lab chat
86
+ const conversation = await tx.conversation.create({
87
+ data: {
88
+ type: 'GROUP',
89
+ name: `Lab: ${title}`,
90
+ displayInChat: false, // Lab chats don't show in regular chat list
91
+ },
92
+ });
93
+
94
+ // Add only teachers to the conversation (this is for course material creation)
95
+ const teacherMembers = classWithTeachers.teachers.map(t => ({
96
+ userId: t.id,
97
+ role: 'ADMIN' as const
98
+ }));
99
+
100
+ await tx.conversationMember.createMany({
101
+ data: teacherMembers.map(member => ({
102
+ userId: member.userId,
103
+ conversationId: conversation.id,
104
+ role: member.role,
105
+ })),
106
+ });
107
+
108
+ // Create the lab chat
109
+ const labChat = await tx.labChat.create({
110
+ data: {
111
+ title,
112
+ context,
113
+ classId,
114
+ conversationId: conversation.id,
115
+ createdById: userId,
116
+ },
117
+ include: {
118
+ conversation: {
119
+ include: {
120
+ members: {
121
+ include: {
122
+ user: {
123
+ select: {
124
+ id: true,
125
+ username: true,
126
+ profile: {
127
+ select: {
128
+ displayName: true,
129
+ profilePicture: true,
130
+ },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ createdBy: {
139
+ select: {
140
+ id: true,
141
+ username: true,
142
+ profile: {
143
+ select: {
144
+ displayName: true,
145
+ },
146
+ },
147
+ },
148
+ },
149
+ class: {
150
+ select: {
151
+ id: true,
152
+ name: true,
153
+ subject: true,
154
+ section: true,
155
+ },
156
+ },
157
+ },
158
+ });
159
+
160
+ return labChat;
161
+ });
162
+
163
+ // Generate AI introduction message in parallel (don't await - fire and forget)
164
+ generateAndSendLabIntroduction(result.id, result.conversationId, context, classWithTeachers.subject || 'Lab').catch(error => {
165
+ logger.error('Failed to generate AI introduction:', { error, labChatId: result.id });
166
+ });
167
+
168
+ // Broadcast lab chat creation to class members
169
+ try {
170
+ await pusher.trigger(`class-${classId}`, 'lab-chat-created', {
171
+ id: result.id,
172
+ title: result.title,
173
+ classId: result.classId,
174
+ conversationId: result.conversationId,
175
+ createdBy: result.createdBy,
176
+ createdAt: result.createdAt,
177
+ });
178
+ } catch (error) {
179
+ console.error('Failed to broadcast lab chat creation:', error);
180
+ // Don't fail the request if Pusher fails
181
+ }
182
+
183
+ return result;
184
+ }),
185
+
186
+ get: protectedProcedure
187
+ .input(z.object({ labChatId: z.string() }))
188
+ .query(async ({ input, ctx }) => {
189
+ const userId = ctx.user!.id;
190
+ const { labChatId } = input;
191
+
192
+ // First, try to find the lab chat if user is already a member
193
+ let labChat = await prisma.labChat.findFirst({
194
+ where: {
195
+ id: labChatId,
196
+ conversation: {
197
+ members: {
198
+ some: {
199
+ userId,
200
+ },
201
+ },
202
+ },
203
+ },
204
+ include: {
205
+ conversation: {
206
+ include: {
207
+ members: {
208
+ include: {
209
+ user: {
210
+ select: {
211
+ id: true,
212
+ username: true,
213
+ profile: {
214
+ select: {
215
+ displayName: true,
216
+ profilePicture: true,
217
+ },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ createdBy: {
226
+ select: {
227
+ id: true,
228
+ username: true,
229
+ profile: {
230
+ select: {
231
+ displayName: true,
232
+ },
233
+ },
234
+ },
235
+ },
236
+ class: {
237
+ select: {
238
+ id: true,
239
+ name: true,
240
+ subject: true,
241
+ section: true,
242
+ },
243
+ },
244
+ },
245
+ });
246
+
247
+ // If not found, check if user is a teacher in the class
248
+ if (!labChat) {
249
+ const labChatForTeacher = await prisma.labChat.findFirst({
250
+ where: {
251
+ id: labChatId,
252
+ class: {
253
+ teachers: {
254
+ some: {
255
+ id: userId,
256
+ },
257
+ },
258
+ },
259
+ },
260
+ include: {
261
+ conversation: {
262
+ select: {
263
+ id: true,
264
+ },
265
+ },
266
+ },
267
+ });
268
+
269
+ if (labChatForTeacher) {
270
+ // Add teacher to conversation
271
+ await prisma.conversationMember.create({
272
+ data: {
273
+ userId,
274
+ conversationId: labChatForTeacher.conversation.id,
275
+ role: 'ADMIN',
276
+ },
277
+ });
278
+
279
+ // Now fetch the full lab chat with the user as a member
280
+ labChat = await prisma.labChat.findFirst({
281
+ where: {
282
+ id: labChatId,
283
+ },
284
+ include: {
285
+ conversation: {
286
+ include: {
287
+ members: {
288
+ include: {
289
+ user: {
290
+ select: {
291
+ id: true,
292
+ username: true,
293
+ profile: {
294
+ select: {
295
+ displayName: true,
296
+ profilePicture: true,
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ },
305
+ createdBy: {
306
+ select: {
307
+ id: true,
308
+ username: true,
309
+ profile: {
310
+ select: {
311
+ displayName: true,
312
+ },
313
+ },
314
+ },
315
+ },
316
+ class: {
317
+ select: {
318
+ id: true,
319
+ name: true,
320
+ subject: true,
321
+ section: true,
322
+ },
323
+ },
324
+ },
325
+ });
326
+ }
327
+ }
328
+
329
+ if (!labChat) {
330
+ throw new TRPCError({
331
+ code: 'NOT_FOUND',
332
+ message: 'Lab chat not found or access denied',
333
+ });
334
+ }
335
+
336
+ return labChat;
337
+ }),
338
+
339
+ list: protectedProcedure
340
+ .input(z.object({ classId: z.string() }))
341
+ .query(async ({ input, ctx }) => {
342
+ const userId = ctx.user!.id;
343
+ const { classId } = input;
344
+
345
+ // Verify user is a member of the class
346
+ const classMembership = await prisma.class.findFirst({
347
+ where: {
348
+ id: classId,
349
+ OR: [
350
+ {
351
+ students: {
352
+ some: {
353
+ id: userId,
354
+ },
355
+ },
356
+ },
357
+ {
358
+ teachers: {
359
+ some: {
360
+ id: userId,
361
+ },
362
+ },
363
+ },
364
+ ],
365
+ },
366
+ });
367
+
368
+ if (!classMembership) {
369
+ throw new TRPCError({
370
+ code: 'FORBIDDEN',
371
+ message: 'Not a member of this class',
372
+ });
373
+ }
374
+
375
+ const labChats = await prisma.labChat.findMany({
376
+ where: {
377
+ classId,
378
+ },
379
+ include: {
380
+ createdBy: {
381
+ select: {
382
+ id: true,
383
+ username: true,
384
+ profile: {
385
+ select: {
386
+ displayName: true,
387
+ },
388
+ },
389
+ },
390
+ },
391
+ conversation: {
392
+ include: {
393
+ messages: {
394
+ orderBy: {
395
+ createdAt: 'desc',
396
+ },
397
+ take: 1,
398
+ include: {
399
+ sender: {
400
+ select: {
401
+ id: true,
402
+ username: true,
403
+ profile: {
404
+ select: {
405
+ displayName: true,
406
+ },
407
+ },
408
+ },
409
+ },
410
+ },
411
+ },
412
+ _count: {
413
+ select: {
414
+ messages: true,
415
+ },
416
+ },
417
+ },
418
+ },
419
+ },
420
+ orderBy: {
421
+ createdAt: 'desc',
422
+ },
423
+ });
424
+
425
+ return labChats.map((labChat) => ({
426
+ id: labChat.id,
427
+ title: labChat.title,
428
+ classId: labChat.classId,
429
+ conversationId: labChat.conversationId,
430
+ createdBy: labChat.createdBy,
431
+ createdAt: labChat.createdAt,
432
+ updatedAt: labChat.updatedAt,
433
+ lastMessage: labChat.conversation.messages[0] || null,
434
+ messageCount: labChat.conversation._count.messages,
435
+ }));
436
+ }),
437
+
438
+ postToLabChat: protectedProcedure
439
+ .input(
440
+ z.object({
441
+ labChatId: z.string(),
442
+ content: z.string().min(1).max(4000),
443
+ mentionedUserIds: z.array(z.string()).optional(),
444
+ })
445
+ )
446
+ .mutation(async ({ input, ctx }) => {
447
+ const userId = ctx.user!.id;
448
+ const { labChatId, content, mentionedUserIds = [] } = input;
449
+
450
+ // Get lab chat and verify user is a member
451
+ const labChat = await prisma.labChat.findFirst({
452
+ where: {
453
+ id: labChatId,
454
+ conversation: {
455
+ members: {
456
+ some: {
457
+ userId,
458
+ },
459
+ },
460
+ },
461
+ },
462
+ include: {
463
+ conversation: {
464
+ select: {
465
+ id: true,
466
+ },
467
+ },
468
+ },
469
+ });
470
+
471
+ if (!labChat) {
472
+ throw new TRPCError({
473
+ code: 'FORBIDDEN',
474
+ message: 'Lab chat not found or access denied',
475
+ });
476
+ }
477
+
478
+ // Verify mentioned users are members of the conversation
479
+ if (mentionedUserIds.length > 0) {
480
+ const mentionedMemberships = await prisma.conversationMember.findMany({
481
+ where: {
482
+ conversationId: labChat.conversationId,
483
+ userId: { in: mentionedUserIds },
484
+ },
485
+ });
486
+
487
+ if (mentionedMemberships.length !== mentionedUserIds.length) {
488
+ throw new TRPCError({
489
+ code: 'BAD_REQUEST',
490
+ message: 'Some mentioned users are not members of this lab chat',
491
+ });
492
+ }
493
+ }
494
+
495
+ // Create message and mentions
496
+ const result = await prisma.$transaction(async (tx) => {
497
+ const message = await tx.message.create({
498
+ data: {
499
+ content,
500
+ senderId: userId,
501
+ conversationId: labChat.conversationId,
502
+ },
503
+ include: {
504
+ sender: {
505
+ select: {
506
+ id: true,
507
+ username: true,
508
+ profile: {
509
+ select: {
510
+ displayName: true,
511
+ profilePicture: true,
512
+ },
513
+ },
514
+ },
515
+ },
516
+ },
517
+ });
518
+
519
+ // Create mentions
520
+ if (mentionedUserIds.length > 0) {
521
+ await tx.mention.createMany({
522
+ data: mentionedUserIds.map((mentionedUserId) => ({
523
+ messageId: message.id,
524
+ userId: mentionedUserId,
525
+ })),
526
+ });
527
+ }
528
+
529
+ // Update lab chat timestamp
530
+ await tx.labChat.update({
531
+ where: { id: labChatId },
532
+ data: { updatedAt: new Date() },
533
+ });
534
+
535
+ return message;
536
+ });
537
+
538
+ // Broadcast to Pusher channel (same format as regular chat)
539
+ try {
540
+ await pusher.trigger(`conversation-${labChat.conversationId}`, 'new-message', {
541
+ id: result.id,
542
+ content: result.content,
543
+ senderId: result.senderId,
544
+ conversationId: result.conversationId,
545
+ createdAt: result.createdAt,
546
+ sender: result.sender,
547
+ mentionedUserIds,
548
+ });
549
+ } catch (error) {
550
+ console.error('Failed to broadcast lab chat message:', error);
551
+ // Don't fail the request if Pusher fails
552
+ }
553
+
554
+ // Generate AI response in parallel (don't await - fire and forget)
555
+ if (!isAIUser(userId)) {
556
+ // Run AI response generation in background
557
+ generateAndSendLabResponse(labChatId, content, labChat.conversationId).catch(error => {
558
+ logger.error('Failed to generate AI response:', { error });
559
+ });
560
+ }
561
+
562
+ return {
563
+ id: result.id,
564
+ content: result.content,
565
+ senderId: result.senderId,
566
+ conversationId: result.conversationId,
567
+ createdAt: result.createdAt,
568
+ sender: result.sender,
569
+ mentionedUserIds,
570
+ };
571
+ }),
572
+
573
+ delete: protectedProcedure
574
+ .input(z.object({ labChatId: z.string() }))
575
+ .mutation(async ({ input, ctx }) => {
576
+ const userId = ctx.user!.id;
577
+ const { labChatId } = input;
578
+
579
+ // Verify user is the creator of the lab chat
580
+ const labChat = await prisma.labChat.findFirst({
581
+ where: {
582
+ id: labChatId,
583
+ createdById: userId,
584
+ },
585
+ });
586
+
587
+ if (!labChat) {
588
+ throw new TRPCError({
589
+ code: 'FORBIDDEN',
590
+ message: 'Lab chat not found or not the creator',
591
+ });
592
+ }
593
+
594
+ // Delete lab chat and associated conversation
595
+ await prisma.$transaction(async (tx) => {
596
+ // Delete mentions first
597
+ await tx.mention.deleteMany({
598
+ where: {
599
+ message: {
600
+ conversationId: labChat.conversationId,
601
+ },
602
+ },
603
+ });
604
+
605
+ // Delete messages
606
+ await tx.message.deleteMany({
607
+ where: {
608
+ conversationId: labChat.conversationId,
609
+ },
610
+ });
611
+
612
+ // Delete conversation members
613
+ await tx.conversationMember.deleteMany({
614
+ where: {
615
+ conversationId: labChat.conversationId,
616
+ },
617
+ });
618
+
619
+ // Delete lab chat
620
+ await tx.labChat.delete({
621
+ where: { id: labChatId },
622
+ });
623
+
624
+ // Delete conversation
625
+ await tx.conversation.delete({
626
+ where: { id: labChat.conversationId },
627
+ });
628
+ });
629
+
630
+ // Broadcast lab chat deletion
631
+ try {
632
+ await pusher.trigger(`class-${labChat.classId}`, 'lab-chat-deleted', {
633
+ labChatId,
634
+ classId: labChat.classId,
635
+ });
636
+ } catch (error) {
637
+ console.error('Failed to broadcast lab chat deletion:', error);
638
+ // Don't fail the request if Pusher fails
639
+ }
640
+
641
+ return { success: true };
642
+ }),
643
+ });
644
+
645
+ /**
646
+ * Generate and send AI introduction for lab chat
647
+ * Uses the stored context directly from database
648
+ */
649
+ async function generateAndSendLabIntroduction(
650
+ labChatId: string,
651
+ conversationId: string,
652
+ contextString: string,
653
+ subject: string
654
+ ): Promise<void> {
655
+ try {
656
+ // Enhance the stored context with clarifying question instructions
657
+ const enhancedSystemPrompt = `${contextString}
658
+
659
+ IMPORTANT INSTRUCTIONS:
660
+ - You are helping teachers create course materials
661
+ - Use the context information provided above (subject, topic, difficulty, objectives, etc.) as your foundation
662
+ - Only ask clarifying questions about details NOT already specified in the context
663
+ - Focus your questions on format preferences, specific requirements, or missing details needed to create the content
664
+ - Only output final course materials when you have sufficient details beyond what's in the context
665
+ - Do not use markdown formatting in your responses - use plain text only
666
+ - When creating content, make it clear and well-structured without markdown`;
667
+
668
+ const completion = await inferenceClient.chat.completions.create({
669
+ model: 'command-a-03-2025',
670
+ messages: [
671
+ { role: 'system', content: enhancedSystemPrompt },
672
+ {
673
+ role: 'user',
674
+ content: 'Please introduce yourself to the teaching team. Explain that you will help create course materials by first asking clarifying questions based on the context provided, and only output final content when you have enough information.'
675
+ },
676
+ ],
677
+ max_tokens: 300,
678
+ temperature: 0.8,
679
+ });
680
+
681
+ const response = completion.choices[0]?.message?.content;
682
+
683
+ if (!response) {
684
+ throw new Error('No response generated from inference API');
685
+ }
686
+
687
+ // Send AI introduction using centralized sender
688
+ await sendAIMessage(response, conversationId, {
689
+ subject,
690
+ });
691
+
692
+ logger.info('AI Introduction sent', { labChatId, conversationId });
693
+
694
+ } catch (error) {
695
+ logger.error('Failed to generate AI introduction:', { error, labChatId });
696
+
697
+ // Send fallback introduction
698
+ try {
699
+ const fallbackIntro = `Hello teaching team! I'm your AI assistant for course material development. I will help you create educational content by first asking clarifying questions based on the provided context, then outputting final materials when I have sufficient information. I won't use markdown formatting in my responses. What would you like to work on?`;
700
+
701
+ await sendAIMessage(fallbackIntro, conversationId, {
702
+ subject,
703
+ });
704
+
705
+ logger.info('Fallback AI introduction sent', { labChatId });
706
+
707
+ } catch (fallbackError) {
708
+ logger.error('Failed to send fallback AI introduction:', { error: fallbackError, labChatId });
709
+ }
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Generate and send AI response to teacher message
715
+ * Uses the stored context directly from database
716
+ */
717
+ async function generateAndSendLabResponse(
718
+ labChatId: string,
719
+ teacherMessage: string,
720
+ conversationId: string
721
+ ): Promise<void> {
722
+ try {
723
+ // Get lab context from database
724
+ const fullLabChat = await prisma.labChat.findUnique({
725
+ where: { id: labChatId },
726
+ include: {
727
+ class: {
728
+ select: {
729
+ name: true,
730
+ subject: true,
731
+ },
732
+ },
733
+ },
734
+ });
735
+
736
+ if (!fullLabChat) {
737
+ throw new Error('Lab chat not found');
738
+ }
739
+
740
+ // Get recent conversation history
741
+ const recentMessages = await prisma.message.findMany({
742
+ where: {
743
+ conversationId,
744
+ },
745
+ include: {
746
+ sender: {
747
+ select: {
748
+ id: true,
749
+ username: true,
750
+ profile: {
751
+ select: {
752
+ displayName: true,
753
+ },
754
+ },
755
+ },
756
+ },
757
+ },
758
+ orderBy: {
759
+ createdAt: 'desc',
760
+ },
761
+ take: 10, // Last 10 messages for context
762
+ });
763
+
764
+ // Build conversation history as proper message objects
765
+ // Enhance the stored context with clarifying question instructions
766
+ const enhancedSystemPrompt = `${fullLabChat.context}
767
+
768
+ IMPORTANT INSTRUCTIONS:
769
+ - Use the context information provided above (subject, topic, difficulty, objectives, etc.) as your foundation
770
+ - Based on the teacher's input and existing context, only ask clarifying questions about details NOT already specified
771
+ - Focus questions on format preferences, specific requirements, quantity, or missing implementation details
772
+ - Only output final course materials when you have sufficient details beyond what's in the context
773
+ - Do not use markdown formatting in your responses - use plain text only
774
+ - When you do create content, make it clear and well-structured without markdown
775
+ - If the request is vague, ask 1-2 specific clarifying questions about missing details only`;
776
+
777
+ const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
778
+ { role: 'system', content: enhancedSystemPrompt },
779
+ ];
780
+
781
+ // Add recent conversation history
782
+ recentMessages.reverse().forEach(msg => {
783
+ const role = isAIUser(msg.senderId) ? 'assistant' : 'user';
784
+ const senderName = msg.sender?.profile?.displayName || msg.sender?.username || 'Teacher';
785
+ const content = isAIUser(msg.senderId) ? msg.content : `${senderName}: ${msg.content}`;
786
+
787
+ messages.push({
788
+ role: role as 'user' | 'assistant',
789
+ content,
790
+ });
791
+ });
792
+
793
+ // Add the new teacher message
794
+ const senderName = 'Teacher'; // We could get this from the actual sender if needed
795
+ messages.push({
796
+ role: 'user',
797
+ content: `${senderName}: ${teacherMessage}`,
798
+ });
799
+
800
+ const completion = await inferenceClient.chat.completions.create({
801
+ model: 'command-a-03-2025',
802
+ messages,
803
+ max_tokens: 500,
804
+ temperature: 0.7,
805
+ });
806
+
807
+ const response = completion.choices[0]?.message?.content;
808
+
809
+ if (!response) {
810
+ throw new Error('No response generated from inference API');
811
+ }
812
+
813
+ // Send AI response
814
+ await sendAIMessage(response, conversationId, {
815
+ subject: fullLabChat.class?.subject || 'Lab',
816
+ });
817
+
818
+ logger.info('AI response sent', { labChatId, conversationId });
819
+
820
+ } catch (error) {
821
+ logger.error('Failed to generate AI response:', { error, labChatId });
822
+ }
823
+ }
824
+