@studious-lms/server 1.1.13 → 1.1.15

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.
@@ -6,6 +6,9 @@ import { TRPCError } from '@trpc/server';
6
6
  import { inferenceClient, sendAIMessage } from '../utils/inference.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
  import { isAIUser } from '../utils/aiUser.js';
9
+ import { uploadFile } from 'src/lib/googleCloudStorage.js';
10
+ import { createPdf } from "src/lib/jsonConversion.js";
11
+ import { v4 as uuidv4 } from "uuid";
9
12
  export const labChatRouter = createTRPCRouter({
10
13
  create: protectedProcedure
11
14
  .input(z.object({
@@ -699,7 +702,30 @@ IMPORTANT INSTRUCTIONS:
699
702
  - Only output final course materials when you have sufficient details beyond what's in the context
700
703
  - Do not use markdown formatting in your responses - use plain text only
701
704
  - 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`;
705
+ - If the request is vague, ask 1-2 specific clarifying questions about missing details only
706
+ - You are primarily a chatbot - only provide files when it is necessary
707
+
708
+ RESPONSE FORMAT:
709
+ - Always respond with JSON in this format: { "text": string, "docs": null | array }
710
+ - "text": Your conversational response (questions, explanations, etc.) - use plain text, no markdown
711
+ - "docs": null for regular conversation, or array of PDF document objects when creating course materials
712
+
713
+ WHEN CREATING COURSE MATERIALS (docs field):
714
+ - docs: [ { "title": string, "blocks": [ { "format": <int 0-12>, "content": string | string[], "metadata"?: { fontSize?: number, lineHeight?: number, paragraphSpacing?: number, indentWidth?: number, paddingX?: number, paddingY?: number, font?: 0|1|2|3|4|5, color?: "#RGB"|"#RRGGBB", background?: "#RGB"|"#RRGGBB", align?: "left"|"center"|"right" } } ] } ]
715
+ - Each document in the array should have a "title" (used for filename) and "blocks" (content)
716
+ - You can create multiple documents when it makes sense (e.g., separate worksheets, answer keys, different topics)
717
+ - Use descriptive titles like "Biology_Cell_Structure_Worksheet" or "Chemistry_Lab_Instructions"
718
+ - Format enum (integers): 0=HEADER_1, 1=HEADER_2, 2=HEADER_3, 3=HEADER_4, 4=HEADER_5, 5=HEADER_6, 6=PARAGRAPH, 7=BULLET, 8=NUMBERED, 9=TABLE, 10=IMAGE, 11=CODE_BLOCK, 12=QUOTE
719
+ - Fonts enum: 0=TIMES_ROMAN, 1=COURIER, 2=HELVETICA, 3=HELVETICA_BOLD, 4=HELVETICA_ITALIC, 5=HELVETICA_BOLD_ITALIC
720
+ - Colors must be hex strings: "#RGB" or "#RRGGBB".
721
+ - Headings (0-5): content is a single string; you may set metadata.align.
722
+ - Paragraphs (6) and Quotes (12): content is a single string.
723
+ - Bullets (7) and Numbered (8): content is an array of strings (one item per list entry).
724
+ - Code blocks (11): prefer content as an array of lines; preserve indentation via leading tabs/spaces. If using a single string, include \n between lines.
725
+ - Table (9) and Image (10) are not supported by the renderer now; do not emit them.
726
+ - Use metadata sparingly; omit fields you don't need. For code blocks you may set metadata.paddingX, paddingY, background, and font (1 for Courier).
727
+ - Wrap text naturally; do not insert manual line breaks except where semantically required (lists, code).
728
+ - The JSON must be valid and ready for PDF rendering by the server.`;
703
729
  const messages = [
704
730
  { role: 'system', content: enhancedSystemPrompt },
705
731
  ];
@@ -722,20 +748,146 @@ IMPORTANT INSTRUCTIONS:
722
748
  const completion = await inferenceClient.chat.completions.create({
723
749
  model: 'command-a-03-2025',
724
750
  messages,
725
- max_tokens: 500,
726
751
  temperature: 0.7,
752
+ response_format: {
753
+ type: "json_object",
754
+ // @ts-expect-error
755
+ schema: {
756
+ type: "object",
757
+ properties: {
758
+ text: { type: "string" },
759
+ docs: {
760
+ type: "array",
761
+ items: {
762
+ type: "object",
763
+ properties: {
764
+ title: { type: "string" },
765
+ blocks: {
766
+ type: "array",
767
+ items: {
768
+ type: "object",
769
+ properties: {
770
+ format: { type: "integer", minimum: 0, maximum: 12 },
771
+ content: {
772
+ oneOf: [
773
+ { type: "string" },
774
+ { type: "array", items: { type: "string" } }
775
+ ]
776
+ },
777
+ metadata: {
778
+ type: "object",
779
+ properties: {
780
+ fontSize: { type: "number", minimum: 6 },
781
+ lineHeight: { type: "number", minimum: 0.6 },
782
+ paragraphSpacing: { type: "number", minimum: 0 },
783
+ indentWidth: { type: "number", minimum: 0 },
784
+ paddingX: { type: "number", minimum: 0 },
785
+ paddingY: { type: "number", minimum: 0 },
786
+ font: { type: "integer", minimum: 0, maximum: 5 },
787
+ color: { type: "string", pattern: "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" },
788
+ background: { type: "string", pattern: "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" },
789
+ align: { type: "string", enum: ["left", "center", "right"] }
790
+ },
791
+ additionalProperties: false
792
+ }
793
+ },
794
+ required: ["format", "content"],
795
+ additionalProperties: false
796
+ }
797
+ }
798
+ },
799
+ required: ["title", "blocks"],
800
+ additionalProperties: false
801
+ }
802
+ }
803
+ },
804
+ required: ["text"],
805
+ additionalProperties: false
806
+ }
807
+ },
727
808
  });
728
809
  const response = completion.choices[0]?.message?.content;
729
810
  if (!response) {
730
811
  throw new Error('No response generated from inference API');
731
812
  }
732
- // Send AI response
733
- await sendAIMessage(response, conversationId, {
734
- subject: fullLabChat.class?.subject || 'Lab',
735
- });
813
+ // Parse the JSON response and generate PDF if docs are provided
814
+ try {
815
+ const jsonData = JSON.parse(response);
816
+ const attachmentIds = [];
817
+ // Generate PDFs if docs are provided
818
+ if (jsonData.docs && Array.isArray(jsonData.docs)) {
819
+ for (let i = 0; i < jsonData.docs.length; i++) {
820
+ const doc = jsonData.docs[i];
821
+ if (!doc.title || !doc.blocks || !Array.isArray(doc.blocks)) {
822
+ logger.error(`Document ${i + 1} is missing title or blocks`);
823
+ continue;
824
+ }
825
+ try {
826
+ let pdfBytes = await createPdf(doc.blocks);
827
+ if (pdfBytes) {
828
+ // Sanitize filename - remove special characters and limit length
829
+ const sanitizedTitle = doc.title
830
+ .replace(/[^a-zA-Z0-9\s\-_]/g, '')
831
+ .replace(/\s+/g, '_')
832
+ .substring(0, 50);
833
+ const filename = `${sanitizedTitle}_${uuidv4().substring(0, 8)}.pdf`;
834
+ logger.info(`PDF ${i + 1} generated successfully`, { labChatId, title: doc.title });
835
+ const gcpResult = await uploadFile(Buffer.from(pdfBytes).toString('base64'), `class/generated/${fullLabChat.classId}/${filename}`, 'application/pdf');
836
+ logger.info(`PDF ${i + 1} uploaded successfully`, { labChatId, filename });
837
+ const file = await prisma.file.create({
838
+ data: {
839
+ name: filename,
840
+ path: `class/generated/${fullLabChat.classId}/${filename}`,
841
+ type: 'application/pdf',
842
+ userId: fullLabChat.createdById,
843
+ },
844
+ });
845
+ attachmentIds.push(file.id);
846
+ }
847
+ else {
848
+ logger.error(`PDF ${i + 1} creation returned undefined/null`, { labChatId, title: doc.title });
849
+ }
850
+ }
851
+ catch (pdfError) {
852
+ logger.error(`PDF creation threw an error for document ${i + 1}:`, {
853
+ error: pdfError instanceof Error ? {
854
+ message: pdfError.message,
855
+ stack: pdfError.stack,
856
+ name: pdfError.name
857
+ } : pdfError,
858
+ labChatId,
859
+ title: doc.title
860
+ });
861
+ }
862
+ }
863
+ }
864
+ // Send the text response to the conversation
865
+ await sendAIMessage(jsonData.text || response, conversationId, {
866
+ attachments: {
867
+ connect: attachmentIds.map(id => ({ id })),
868
+ },
869
+ subject: fullLabChat.class?.subject || 'Lab',
870
+ });
871
+ }
872
+ catch (parseError) {
873
+ logger.error('Failed to parse AI response or generate PDF:', { error: parseError, labChatId });
874
+ // Fallback: send the raw response if parsing fails
875
+ await sendAIMessage(response, conversationId, {
876
+ subject: fullLabChat.class?.subject || 'Lab',
877
+ });
878
+ }
736
879
  logger.info('AI response sent', { labChatId, conversationId });
737
880
  }
738
881
  catch (error) {
739
- logger.error('Failed to generate AI response:', { error, labChatId });
882
+ console.error('Full error object:', error);
883
+ logger.error('Failed to generate AI response:', {
884
+ error: error instanceof Error ? {
885
+ message: error.message,
886
+ stack: error.stack,
887
+ name: error.name
888
+ } : error,
889
+ labChatId
890
+ });
891
+ throw error; // Re-throw to see the full error in the calling function
740
892
  }
741
893
  }
@@ -37,6 +37,11 @@ export declare const messageRouter: import("@trpc/server").TRPCBuiltRouter<{
37
37
  profilePicture: string | null;
38
38
  } | null;
39
39
  };
40
+ attachments: {
41
+ id: string;
42
+ name: string;
43
+ type: string;
44
+ }[];
40
45
  mentions: {
41
46
  user: {
42
47
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/routers/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6kBxB,CAAC"}
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/routers/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAOxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAylBxB,CAAC"}
@@ -3,6 +3,7 @@ import { createTRPCRouter, protectedProcedure } from '../trpc.js';
3
3
  import { prisma } from '../lib/prisma.js';
4
4
  import { pusher } from '../lib/pusher.js';
5
5
  import { TRPCError } from '@trpc/server';
6
+ import { logger } from '../utils/logger.js';
6
7
  export const messageRouter = createTRPCRouter({
7
8
  list: protectedProcedure
8
9
  .input(z.object({
@@ -36,6 +37,13 @@ export const messageRouter = createTRPCRouter({
36
37
  }),
37
38
  },
38
39
  include: {
40
+ attachments: {
41
+ select: {
42
+ id: true,
43
+ name: true,
44
+ type: true,
45
+ },
46
+ },
39
47
  sender: {
40
48
  select: {
41
49
  id: true,
@@ -82,6 +90,11 @@ export const messageRouter = createTRPCRouter({
82
90
  conversationId: message.conversationId,
83
91
  createdAt: message.createdAt,
84
92
  sender: message.sender,
93
+ attachments: message.attachments.map((attachment) => ({
94
+ id: attachment.id,
95
+ name: attachment.name,
96
+ type: attachment.type,
97
+ })),
85
98
  mentions: message.mentions.map((mention) => ({
86
99
  user: mention.user,
87
100
  })),
@@ -179,7 +192,7 @@ export const messageRouter = createTRPCRouter({
179
192
  });
180
193
  }
181
194
  catch (error) {
182
- console.error('Failed to broadcast message:', error);
195
+ logger.error('Failed to broadcast message:', { error });
183
196
  // Don't fail the request if Pusher fails
184
197
  }
185
198
  return {
@@ -308,7 +321,7 @@ export const messageRouter = createTRPCRouter({
308
321
  });
309
322
  }
310
323
  catch (error) {
311
- console.error('Failed to broadcast message update:', error);
324
+ logger.error('Failed to broadcast message update:', { error });
312
325
  // Don't fail the request if Pusher fails
313
326
  }
314
327
  return {
@@ -385,7 +398,7 @@ export const messageRouter = createTRPCRouter({
385
398
  });
386
399
  }
387
400
  catch (error) {
388
- console.error('Failed to broadcast message deletion:', error);
401
+ logger.error('Failed to broadcast message deletion:', { error });
389
402
  // Don't fail the request if Pusher fails
390
403
  }
391
404
  return {
@@ -430,7 +443,7 @@ export const messageRouter = createTRPCRouter({
430
443
  });
431
444
  }
432
445
  catch (error) {
433
- console.error('Failed to broadcast conversation view:', error);
446
+ logger.error('Failed to broadcast conversation view:', { error });
434
447
  // Don't fail the request if Pusher fails
435
448
  }
436
449
  return { success: true };
@@ -472,7 +485,7 @@ export const messageRouter = createTRPCRouter({
472
485
  });
473
486
  }
474
487
  catch (error) {
475
- console.error('Failed to broadcast mentions view:', error);
488
+ logger.error('Failed to broadcast mentions view:', { error });
476
489
  // Don't fail the request if Pusher fails
477
490
  }
478
491
  return { success: true };
@@ -23,6 +23,11 @@ export interface InferenceResponse {
23
23
  */
24
24
  export declare function sendAIMessage(content: string, conversationId: string, options?: {
25
25
  subject?: string;
26
+ attachments?: {
27
+ connect: {
28
+ id: string;
29
+ }[];
30
+ };
26
31
  customSender?: {
27
32
  displayName: string;
28
33
  profilePicture?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"inference.d.ts","sourceRoot":"","sources":["../../src/utils/inference.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAC;AAW5B,eAAO,MAAM,eAAe,QAG1B,CAAC;AAGH,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,UAAU,GAAG,cAAc,GAAG,UAAU,CAAC;IACrD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,EACtB,OAAO,GAAE;IACP,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAChC,CAAC;CACE,GACL,OAAO,CAAC;IACT,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,IAAI,CAAC;CACjB,CAAC,CAmDD;AAED;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IACP,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACf,GACL,OAAO,CAAC,iBAAiB,CAAC,CAsC5B;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,OAAO,CAMjD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAW5D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvD"}
1
+ {"version":3,"file":"inference.d.ts","sourceRoot":"","sources":["../../src/utils/inference.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAC;AAW5B,eAAO,MAAM,eAAe,QAG1B,CAAC;AAGH,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,UAAU,GAAG,cAAc,GAAG,UAAU,CAAC;IACrD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,EACtB,OAAO,GAAE;IACP,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE;QACZ,OAAO,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;KAC3B,CAAC;IACF,YAAY,CAAC,EAAE;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAChC,CAAC;CACE,GACL,OAAO,CAAC;IACT,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,IAAI,CAAC;CACjB,CAAC,CAmED;AAED;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IACP,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACf,GACL,OAAO,CAAC,iBAAiB,CAAC,CAsC5B;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,OAAO,CAMjD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAW5D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvD"}
@@ -23,6 +23,14 @@ export async function sendAIMessage(content, conversationId, options = {}) {
23
23
  content,
24
24
  senderId: getAIUserId(),
25
25
  conversationId,
26
+ ...(options.attachments && {
27
+ attachments: {
28
+ connect: options.attachments.connect,
29
+ },
30
+ }),
31
+ },
32
+ include: {
33
+ attachments: true,
26
34
  },
27
35
  });
28
36
  logger.info('AI Message sent', {
@@ -49,6 +57,14 @@ export async function sendAIMessage(content, conversationId, options = {}) {
49
57
  createdAt: aiMessage.createdAt,
50
58
  sender: senderInfo,
51
59
  mentionedUserIds: [],
60
+ attachments: aiMessage.attachments.map(attachment => ({
61
+ id: attachment.id,
62
+ attachmentId: attachment.id,
63
+ name: attachment.name,
64
+ type: attachment.type,
65
+ size: attachment.size,
66
+ path: attachment.path,
67
+ })),
52
68
  });
53
69
  }
54
70
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -31,6 +31,7 @@
31
31
  "express": "^4.18.3",
32
32
  "nodemailer": "^7.0.4",
33
33
  "openai": "^5.23.0",
34
+ "pdf-lib": "^1.17.1",
34
35
  "prisma": "^6.7.0",
35
36
  "pusher": "^5.2.0",
36
37
  "sharp": "^0.34.2",
@@ -183,6 +183,11 @@ model File {
183
183
  folder Folder? @relation("FolderFiles", fields: [folderId], references: [id], onDelete: Cascade)
184
184
  folderId String?
185
185
 
186
+ conversationId String?
187
+
188
+ messageId String?
189
+ message Message? @relation("MessageAttachments", fields: [messageId], references: [id], onDelete: Cascade)
190
+
186
191
  schools School[]
187
192
  }
188
193
 
@@ -379,6 +384,7 @@ model Message {
379
384
  sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade)
380
385
  conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
381
386
  mentions Mention[]
387
+ attachments File[] @relation("MessageAttachments")
382
388
  }
383
389
 
384
390
  model Mention {
package/src/index.ts CHANGED
@@ -88,6 +88,10 @@ app.use((req, res, next) => {
88
88
  // Create HTTP server
89
89
  const httpServer = createServer(app);
90
90
 
91
+ app.get('/health', (req, res) => {
92
+ res.status(200).json({ message: 'OK' });
93
+ });
94
+
91
95
  // Setup Socket.IO
92
96
  const io = new Server(httpServer, {
93
97
  cors: {