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