@studious-lms/server 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.vscode/settings.json +3 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +68 -29
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts +10 -2
  7. package/dist/lib/fileUpload.d.ts.map +1 -1
  8. package/dist/lib/fileUpload.js +38 -6
  9. package/dist/lib/fileUpload.js.map +1 -1
  10. package/dist/middleware/auth.d.ts.map +1 -1
  11. package/dist/middleware/auth.js +18 -5
  12. package/dist/middleware/auth.js.map +1 -1
  13. package/dist/models/auth.d.ts +19 -14
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +53 -30
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +3 -1
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +16 -4
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/notification.d.ts +11 -10
  22. package/dist/models/notification.d.ts.map +1 -1
  23. package/dist/models/notification.js +13 -9
  24. package/dist/models/notification.js.map +1 -1
  25. package/dist/models/worksheet.d.ts +379 -7
  26. package/dist/models/worksheet.d.ts.map +1 -1
  27. package/dist/models/worksheet.js +168 -2
  28. package/dist/models/worksheet.js.map +1 -1
  29. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  30. package/dist/pipelines/aiLabChat.js +11 -3
  31. package/dist/pipelines/aiLabChat.js.map +1 -1
  32. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +25 -14
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  37. package/dist/pipelines/gradeWorksheet.js +10 -3
  38. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  39. package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
  40. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
  41. package/dist/pipelines/pdfToWorksheet.js +155 -0
  42. package/dist/pipelines/pdfToWorksheet.js.map +1 -0
  43. package/dist/routers/_app.d.ts +264 -16
  44. package/dist/routers/_app.d.ts.map +1 -1
  45. package/dist/routers/class.d.ts +2 -1
  46. package/dist/routers/class.d.ts.map +1 -1
  47. package/dist/routers/class.js +3 -3
  48. package/dist/routers/class.js.map +1 -1
  49. package/dist/routers/labChat.d.ts +6 -0
  50. package/dist/routers/labChat.d.ts.map +1 -1
  51. package/dist/routers/labChat.js +5 -3
  52. package/dist/routers/labChat.js.map +1 -1
  53. package/dist/routers/message.d.ts +31 -0
  54. package/dist/routers/message.d.ts.map +1 -1
  55. package/dist/routers/message.js +27 -4
  56. package/dist/routers/message.js.map +1 -1
  57. package/dist/routers/newtonChat.d.ts +6 -0
  58. package/dist/routers/newtonChat.d.ts.map +1 -1
  59. package/dist/routers/newtonChat.js +5 -4
  60. package/dist/routers/newtonChat.js.map +1 -1
  61. package/dist/routers/notifications.d.ts +9 -2
  62. package/dist/routers/notifications.d.ts.map +1 -1
  63. package/dist/routers/notifications.js +4 -4
  64. package/dist/routers/notifications.js.map +1 -1
  65. package/dist/routers/worksheet.d.ts +78 -5
  66. package/dist/routers/worksheet.d.ts.map +1 -1
  67. package/dist/routers/worksheet.js +126 -17
  68. package/dist/routers/worksheet.js.map +1 -1
  69. package/dist/services/auth.d.ts +1 -1
  70. package/dist/services/auth.d.ts.map +1 -1
  71. package/dist/services/auth.js +19 -12
  72. package/dist/services/auth.js.map +1 -1
  73. package/dist/services/class.d.ts +1 -1
  74. package/dist/services/class.d.ts.map +1 -1
  75. package/dist/services/class.js +6 -3
  76. package/dist/services/class.js.map +1 -1
  77. package/dist/services/file.d.ts.map +1 -1
  78. package/dist/services/file.js +7 -2
  79. package/dist/services/file.js.map +1 -1
  80. package/dist/services/labChat.d.ts +6 -0
  81. package/dist/services/labChat.d.ts.map +1 -1
  82. package/dist/services/labChat.js +30 -18
  83. package/dist/services/labChat.js.map +1 -1
  84. package/dist/services/message.d.ts +30 -0
  85. package/dist/services/message.d.ts.map +1 -1
  86. package/dist/services/message.js +97 -21
  87. package/dist/services/message.js.map +1 -1
  88. package/dist/services/newtonChat.d.ts +6 -1
  89. package/dist/services/newtonChat.d.ts.map +1 -1
  90. package/dist/services/newtonChat.js +36 -18
  91. package/dist/services/newtonChat.js.map +1 -1
  92. package/dist/services/notification.d.ts +12 -5
  93. package/dist/services/notification.d.ts.map +1 -1
  94. package/dist/services/notification.js +24 -9
  95. package/dist/services/notification.js.map +1 -1
  96. package/dist/services/studentProgress.d.ts.map +1 -1
  97. package/dist/services/studentProgress.js +6 -3
  98. package/dist/services/studentProgress.js.map +1 -1
  99. package/dist/services/worksheet.d.ts +36 -15
  100. package/dist/services/worksheet.d.ts.map +1 -1
  101. package/dist/services/worksheet.js +186 -54
  102. package/dist/services/worksheet.js.map +1 -1
  103. package/dist/trpc.d.ts.map +1 -1
  104. package/dist/trpc.js +11 -17
  105. package/dist/trpc.js.map +1 -1
  106. package/dist/utils/inference.d.ts +10 -1
  107. package/dist/utils/inference.d.ts.map +1 -1
  108. package/dist/utils/inference.js +186 -50
  109. package/dist/utils/inference.js.map +1 -1
  110. package/package.json +1 -1
  111. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  112. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  113. package/prisma/schema.prisma +27 -1
  114. package/scripts/test-pre-push.ts +6 -2
  115. package/src/index.ts +83 -30
  116. package/src/lib/fileUpload.ts +55 -5
  117. package/src/middleware/auth.ts +19 -4
  118. package/src/models/auth.ts +59 -29
  119. package/src/models/class.ts +15 -4
  120. package/src/models/notification.ts +13 -7
  121. package/src/models/worksheet.ts +203 -0
  122. package/src/pipelines/aiLabChat.ts +9 -1
  123. package/src/pipelines/aiNewtonChat.ts +24 -14
  124. package/src/pipelines/gradeWorksheet.ts +8 -0
  125. package/src/pipelines/pdfToWorksheet.ts +177 -0
  126. package/src/routers/class.ts +2 -2
  127. package/src/routers/labChat.ts +3 -1
  128. package/src/routers/message.ts +38 -1
  129. package/src/routers/newtonChat.ts +4 -2
  130. package/src/routers/notifications.ts +2 -2
  131. package/src/routers/worksheet.ts +174 -19
  132. package/src/services/auth.ts +26 -12
  133. package/src/services/class.ts +4 -1
  134. package/src/services/file.ts +6 -0
  135. package/src/services/labChat.ts +42 -18
  136. package/src/services/message.ts +163 -18
  137. package/src/services/newtonChat.ts +49 -18
  138. package/src/services/notification.ts +27 -8
  139. package/src/services/studentProgress.ts +4 -1
  140. package/src/services/worksheet.ts +252 -51
  141. package/src/trpc.ts +12 -18
  142. package/src/utils/inference.ts +280 -56
  143. package/tests/globalSetup.ts +13 -4
  144. package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
  145. package/tests/routers/auth.test.ts +120 -17
  146. package/tests/routers/class.test.ts +7 -1
  147. package/tests/routers/message.test.ts +236 -9
  148. package/tests/routers/newtonChat.test.ts +81 -0
  149. package/tests/routers/notification.test.ts +12 -0
  150. package/tests/routers/worksheet.test.ts +194 -0
  151. package/tests/setup.ts +22 -5
  152. package/tests/utils/inference.test.ts +255 -0
@@ -4,6 +4,21 @@
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
6
6
 
7
+ const worksheetClassMemberWhere = (userId: string) => ({
8
+ class: {
9
+ OR: [
10
+ { teachers: { some: { id: userId } } },
11
+ { students: { some: { id: userId } } },
12
+ ],
13
+ },
14
+ });
15
+
16
+ const worksheetTeacherWhere = (userId: string) => ({
17
+ class: {
18
+ teachers: { some: { id: userId } },
19
+ },
20
+ });
21
+
7
22
  /** @returns Worksheet with questions and class. */
8
23
  export function findWorksheetById(id: string) {
9
24
  return prisma.worksheet.findUnique({
@@ -22,6 +37,34 @@ export function findWorksheetByIdMinimal(id: string) {
22
37
  });
23
38
  }
24
39
 
40
+ /** @returns Worksheet by ID for an authorized class member. */
41
+ export function findWorksheetByIdForClassMember(id: string, userId: string) {
42
+ return prisma.worksheet.findFirst({
43
+ where: {
44
+ id,
45
+ ...worksheetClassMemberWhere(userId),
46
+ },
47
+ include: {
48
+ questions: { orderBy: { createdAt: "asc" as const } },
49
+ class: true,
50
+ },
51
+ });
52
+ }
53
+
54
+ /** @returns Worksheet by ID for an authorized teacher. */
55
+ export function findWorksheetByIdForTeacher(id: string, userId: string) {
56
+ return prisma.worksheet.findFirst({
57
+ where: {
58
+ id,
59
+ ...worksheetTeacherWhere(userId),
60
+ },
61
+ include: {
62
+ questions: { orderBy: { createdAt: "asc" as const } },
63
+ class: true,
64
+ },
65
+ });
66
+ }
67
+
25
68
  /** @returns Worksheets for a class. */
26
69
  export function findWorksheetsByClassId(classId: string) {
27
70
  return prisma.worksheet.findMany({
@@ -33,6 +76,26 @@ export function findWorksheetsByClassId(classId: string) {
33
76
  });
34
77
  }
35
78
 
79
+ /** @returns Worksheet question by ID for an authorized teacher. */
80
+ export function findWorksheetQuestionByIdForTeacher(id: string, userId: string) {
81
+ return prisma.worksheetQuestion.findFirst({
82
+ where: {
83
+ id,
84
+ worksheet: worksheetTeacherWhere(userId),
85
+ },
86
+ });
87
+ }
88
+
89
+ /** @returns Worksheet question scoped to a worksheet. */
90
+ export function findWorksheetQuestionByIdInWorksheet(
91
+ id: string,
92
+ worksheetId: string
93
+ ) {
94
+ return prisma.worksheetQuestion.findFirst({
95
+ where: { id, worksheetId },
96
+ });
97
+ }
98
+
36
99
  /** Create a worksheet. */
37
100
  export function createWorksheet(data: { classId: string; name: string }) {
38
101
  return prisma.worksheet.create({
@@ -72,6 +135,7 @@ export function createWorksheetQuestion(data: {
72
135
  points?: number;
73
136
  options?: object;
74
137
  markScheme?: object;
138
+ order?: number;
75
139
  }) {
76
140
  return prisma.worksheetQuestion.create({
77
141
  data,
@@ -118,6 +182,45 @@ export function findSubmissionById(id: string) {
118
182
  });
119
183
  }
120
184
 
185
+ /** @returns Submission if requester is the owning student or a class teacher for the worksheet. */
186
+ export function findSubmissionByIdForWorksheetAccess(
187
+ id: string,
188
+ worksheetId: string,
189
+ worksheetClassId: string,
190
+ userId: string
191
+ ) {
192
+ return prisma.submission.findFirst({
193
+ where: {
194
+ id,
195
+ assignment: {
196
+ classId: worksheetClassId,
197
+ worksheets: { some: { id: worksheetId } },
198
+ },
199
+ OR: [
200
+ {
201
+ studentId: userId,
202
+ assignment: {
203
+ class: {
204
+ students: { some: { id: userId } },
205
+ },
206
+ },
207
+ },
208
+ {
209
+ assignment: {
210
+ class: {
211
+ teachers: { some: { id: userId } },
212
+ },
213
+ },
214
+ },
215
+ ],
216
+ },
217
+ select: {
218
+ id: true,
219
+ studentId: true,
220
+ },
221
+ });
222
+ }
223
+
121
224
  const worksheetResponseInclude = {
122
225
  responses: {
123
226
  include: {
@@ -182,6 +285,29 @@ export function findWorksheetResponseById(id: string) {
182
285
  });
183
286
  }
184
287
 
288
+ /** @returns Worksheet response if requester is the owning student. */
289
+ export function findWorksheetResponseByIdForStudent(id: string, userId: string) {
290
+ return prisma.studentWorksheetResponse.findFirst({
291
+ where: {
292
+ id,
293
+ studentId: userId,
294
+ worksheet: worksheetClassMemberWhere(userId),
295
+ },
296
+ include: { responses: true, worksheet: true },
297
+ });
298
+ }
299
+
300
+ /** @returns Worksheet response if requester is a class teacher. */
301
+ export function findWorksheetResponseByIdForTeacher(id: string, userId: string) {
302
+ return prisma.studentWorksheetResponse.findFirst({
303
+ where: {
304
+ id,
305
+ worksheet: worksheetTeacherWhere(userId),
306
+ },
307
+ include: { responses: true, worksheet: true },
308
+ });
309
+ }
310
+
185
311
  /** @returns Worksheet response with question responses. */
186
312
  export function findWorksheetResponseWithResponses(
187
313
  id: string,
@@ -197,6 +323,43 @@ export function findWorksheetResponseWithResponses(
197
323
  });
198
324
  }
199
325
 
326
+ /** @returns Worksheet response with scoped responses if requester is the owning student. */
327
+ export function findWorksheetResponseWithResponsesForStudent(
328
+ id: string,
329
+ userId: string,
330
+ questionId?: string
331
+ ) {
332
+ return prisma.studentWorksheetResponse.findFirst({
333
+ where: {
334
+ id,
335
+ studentId: userId,
336
+ worksheet: worksheetClassMemberWhere(userId),
337
+ },
338
+ include: {
339
+ worksheet: true,
340
+ responses: questionId ? { where: { questionId } } : true,
341
+ },
342
+ });
343
+ }
344
+
345
+ /** @returns Worksheet response with scoped responses if requester is a class teacher. */
346
+ export function findWorksheetResponseWithResponsesForTeacher(
347
+ id: string,
348
+ userId: string,
349
+ questionId?: string
350
+ ) {
351
+ return prisma.studentWorksheetResponse.findFirst({
352
+ where: {
353
+ id,
354
+ worksheet: worksheetTeacherWhere(userId),
355
+ },
356
+ include: {
357
+ worksheet: true,
358
+ responses: questionId ? { where: { questionId } } : true,
359
+ },
360
+ });
361
+ }
362
+
200
363
  /** @returns Worksheet question by ID. */
201
364
  export function findWorksheetQuestionById(id: string) {
202
365
  return prisma.worksheetQuestion.findUnique({
@@ -204,6 +367,46 @@ export function findWorksheetQuestionById(id: string) {
204
367
  });
205
368
  }
206
369
 
370
+ /** @returns Student question progress if requester is a class teacher. */
371
+ export function findStudentQuestionProgressByIdForTeacher(
372
+ id: string,
373
+ userId: string
374
+ ) {
375
+ return prisma.studentQuestionProgress.findFirst({
376
+ where: {
377
+ id,
378
+ question: {
379
+ worksheet: worksheetTeacherWhere(userId),
380
+ },
381
+ },
382
+ include: {
383
+ question: true,
384
+ studentWorksheetResponse: true,
385
+ },
386
+ });
387
+ }
388
+
389
+ /** @returns Student question progress by id and worksheet response if requester is a class teacher. */
390
+ export function findStudentQuestionProgressByIdForTeacherAndResponse(
391
+ id: string,
392
+ studentWorksheetResponseId: string,
393
+ userId: string
394
+ ) {
395
+ return prisma.studentQuestionProgress.findFirst({
396
+ where: {
397
+ id,
398
+ studentWorksheetResponseId,
399
+ question: {
400
+ worksheet: worksheetTeacherWhere(userId),
401
+ },
402
+ },
403
+ include: {
404
+ question: true,
405
+ studentWorksheetResponse: true,
406
+ },
407
+ });
408
+ }
409
+
207
410
  /** Update student's question progress. */
208
411
  export function updateStudentQuestionProgress(
209
412
  id: string,
@@ -479,7 +479,15 @@ export const generateAndSendLabIntroduction = async (
479
479
  // });
480
480
 
481
481
  const response = await withTimeout(
482
- inference<LabChatResponse>(messages, labChatResponseSchema),
482
+ inference<LabChatResponse>(messages, labChatResponseSchema, {
483
+ reason: 'lab_chat_response',
484
+ metadata: {
485
+ labChatId,
486
+ conversationId,
487
+ classId: fullLabChat.classId,
488
+ ...(emitOptions?.messageId ? { messageId: emitOptions.messageId } : {}),
489
+ },
490
+ }),
483
491
  LAB_CHAT_RESPONSE_TIMEOUT_MS,
484
492
  "Lab chat response generation",
485
493
  );
@@ -3,7 +3,7 @@
3
3
  * Uses assignment context and AI policy levels for tutoring.
4
4
  */
5
5
  import { prisma } from "../lib/prisma.js";
6
- import { inference, inferenceClient, openAIClient } from "../utils/inference.js";
6
+ import { inference } from "../utils/inference.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { sendAIMessage } from "../utils/inference.js";
9
9
  import { isAIUser } from "../utils/aiUser.js";
@@ -132,20 +132,23 @@ export const generateAndSendNewtonIntroduction = async (
132
132
 
133
133
  Do not use markdown formatting in your responses - use plain text only.`;
134
134
 
135
- const completion = await inferenceClient.chat.completions.create({
135
+ const response = await inference<string>([
136
+ { role: 'system', content: systemPrompt },
137
+ {
138
+ role: 'user',
139
+ content: 'Please introduce yourself to the student. Explain that you are Newton, their AI tutor, and you are here to help them with their assignment. Ask them what they would like help with.'
140
+ },
141
+ ], undefined, {
142
+ reason: 'newton_chat_introduction',
143
+ metadata: {
144
+ newtonChatId,
145
+ conversationId,
146
+ submissionId: submission.id,
147
+ },
136
148
  model: 'command-a-03-2025',
137
- messages: [
138
- { role: 'system', content: systemPrompt },
139
- {
140
- role: 'user',
141
- content: 'Please introduce yourself to the student. Explain that you are Newton, their AI tutor, and you are here to help them with their assignment. Ask them what they would like help with.'
142
- },
143
- ],
144
- max_tokens: 300,
149
+ maxTokens: 300,
145
150
  temperature: 0.8,
146
151
  });
147
-
148
- const response = completion.choices[0]?.message?.content;
149
152
 
150
153
  if (!response) {
151
154
  throw new Error('No response generated from inference API');
@@ -320,7 +323,14 @@ export const generateAndSendNewtonIntroduction = async (
320
323
  content: `SYSTEM: ${formatAssignmentString(assignmentData)}`,
321
324
  });
322
325
 
323
- const response = await inference<string>(messages);
326
+ const response = await inference<string>(messages, undefined, {
327
+ reason: 'newton_chat_response',
328
+ metadata: {
329
+ newtonChatId,
330
+ conversationId,
331
+ submissionId: submission.id,
332
+ },
333
+ });
324
334
 
325
335
  if (!response) {
326
336
  throw new Error('No response generated from inference API');
@@ -344,4 +354,4 @@ export const generateAndSendNewtonIntroduction = async (
344
354
  });
345
355
  }
346
356
  }
347
-
357
+
@@ -97,6 +97,14 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
97
97
  })), // @note: this has to be converted to [id: string]: correct boolean
98
98
  comments: z.array(z.string()),
99
99
  }),
100
+ {
101
+ reason: 'worksheet_autograde',
102
+ metadata: {
103
+ worksheetResponseId: worksheetResponse.id,
104
+ questionId: worksheetQuestionProgressId,
105
+ studentId: worksheetResponse.studentId,
106
+ },
107
+ },
100
108
  ).catch((error) => {
101
109
  logger.error('Failed to grade worksheet response', { error });
102
110
  throw error;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * PDF URL → worksheet: send a remote PDF URL to structured inference,
3
+ * then persist a Worksheet and WorksheetQuestion rows.
4
+ */
5
+ import { z } from "zod";
6
+ import type { ResponseInput } from "openai/resources/responses/responses.js";
7
+ import { prisma } from "../lib/prisma.js";
8
+ import { inference } from "../utils/inference.js";
9
+ import { logger } from "../utils/logger.js";
10
+
11
+ /** Restrict remote fetches to Google-hosted object URLs. */
12
+ function isProbablyGoogleCloudUrl(url: string): boolean {
13
+ try {
14
+ const host = new URL(url).hostname;
15
+ return (
16
+ host.endsWith("storage.googleapis.com") ||
17
+ host.endsWith("googleusercontent.com") ||
18
+ host.includes("storage.cloud.google.com")
19
+ );
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function getFilenameFromUrl(url: string): string {
26
+ try {
27
+ const parsed = new URL(url);
28
+ const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
29
+ return lastSegment && lastSegment.includes(".") ? lastSegment : "document.pdf";
30
+ } catch {
31
+ return "document.pdf";
32
+ }
33
+ }
34
+
35
+ function sanitizeUrlForLogs(url: string): string {
36
+ try {
37
+ const parsed = new URL(url);
38
+ return `${parsed.origin}${parsed.pathname}`;
39
+ } catch {
40
+ return 'invalid-url';
41
+ }
42
+ }
43
+
44
+ /** LLM output shape (validated by inference). */
45
+ const worksheetDraftSchema = z.object({
46
+ title: z.string(),
47
+ questions: z
48
+ .array(
49
+ z.object({
50
+ type: z.enum([
51
+ "MULTIPLE_CHOICE",
52
+ "TRUE_FALSE",
53
+ "SHORT_ANSWER",
54
+ "LONG_ANSWER",
55
+ "MATH_EXPRESSION",
56
+ "ESSAY",
57
+ ]),
58
+ question: z.string(),
59
+ answer: z.string(),
60
+ options: z
61
+ .array(
62
+ z.object({
63
+ id: z.string(),
64
+ text: z.string(),
65
+ isCorrect: z.boolean(),
66
+ })
67
+ )
68
+ .optional()
69
+ .default([]),
70
+ markScheme: z
71
+ .array(
72
+ z.object({
73
+ id: z.string(),
74
+ points: z.number().transform((value) => Math.round(value)),
75
+ description: z.string(),
76
+ })
77
+ )
78
+ .optional()
79
+ .default([]),
80
+ points: z.number().optional().default(0),
81
+ order: z.number().transform((value) => Math.round(value)),
82
+ })
83
+ )
84
+ .default([]),
85
+ });
86
+
87
+ type WorksheetDraft = z.infer<typeof worksheetDraftSchema>;
88
+
89
+ const schemaInstructions = `Return ONLY valid JSON matching this schema:
90
+ {
91
+ "title": string,
92
+ "questions": [
93
+ {
94
+ "type": "MULTIPLE_CHOICE" | "TRUE_FALSE" | "SHORT_ANSWER" | "LONG_ANSWER" | "MATH_EXPRESSION" | "ESSAY",
95
+ "question": string,
96
+ "answer": string,
97
+ "options": [ { "id": string, "text": string, "isCorrect": boolean } ],
98
+ "markScheme": [ { "id": string, "points": number, "description": string } ],
99
+ "points": number,
100
+ "order": number
101
+ }
102
+ ]
103
+ }
104
+ Rules: "questions" is an array ([] if none). Non-MCQ "options" can be []. Use "order" 1,2,3,… in reading order. No markdown or extra keys.
105
+ If no answer is provided for a multiple-choice or true/false question, determine the answer from the options.`;
106
+
107
+ /**
108
+ * Sends a PDF URL to the model for remote retrieval, extracts a worksheet via
109
+ * structured inference, and inserts worksheet + questions in one DB transaction.
110
+ */
111
+ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
112
+ const sanitizedUrl = sanitizeUrlForLogs(gcsUrl);
113
+
114
+ if (!isProbablyGoogleCloudUrl(gcsUrl)) {
115
+ logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
116
+ sanitizedUrl,
117
+ });
118
+ throw new Error("Only Google Cloud Storage URLs are supported");
119
+ }
120
+
121
+ const filename = getFilenameFromUrl(gcsUrl);
122
+ const prompt = `Read the attached PDF and convert it into a single worksheet JSON object.\n\n${schemaInstructions}`;
123
+
124
+ // Force Responses API input types so we can pass a remote PDF via `file_url`.
125
+ const input: ResponseInput = [
126
+ {
127
+ role: "user",
128
+ content: [
129
+ { type: "input_text", text: prompt },
130
+ { type: "input_file", file_url: gcsUrl },
131
+ ],
132
+ },
133
+ ];
134
+
135
+ const draft = await inference<WorksheetDraft>(
136
+ input,
137
+ worksheetDraftSchema,
138
+ {
139
+ reason: 'pdf_to_worksheet_draft',
140
+ metadata: {
141
+ classId,
142
+ filename,
143
+ },
144
+ }
145
+ );
146
+
147
+ if (!draft) {
148
+ logger.warn("pdfToWorksheet: no model output", { filename });
149
+ return null;
150
+ }
151
+
152
+ return prisma.$transaction(async (tx) => {
153
+ const worksheet = await tx.worksheet.create({
154
+ data: { classId, name: draft.title },
155
+ });
156
+
157
+ if (draft.questions.length > 0) {
158
+ await tx.worksheetQuestion.createMany({
159
+ data: draft.questions.map((question) => ({
160
+ worksheetId: worksheet.id,
161
+ type: question.type,
162
+ question: question.question,
163
+ answer: question.answer,
164
+ points: Math.round(question.points ?? 0),
165
+ options: question.options ?? [],
166
+ markScheme: (question.markScheme ?? []).map((item) => ({
167
+ ...item,
168
+ points: Math.round(item.points),
169
+ })),
170
+ order: Math.round(question.order),
171
+ })),
172
+ });
173
+ }
174
+
175
+ return worksheet;
176
+ });
177
+ }
@@ -46,7 +46,7 @@ export const classRouter = createTRPCRouter({
46
46
  getAllClasses(ctx.user?.id ?? "")
47
47
  ),
48
48
 
49
- get: protectedProcedure
49
+ get: protectedClassMemberProcedure
50
50
  .input(z.object({ classId: z.string() }))
51
51
  .query(({ ctx, input }) =>
52
52
  getClass(ctx.user?.id ?? "", input.classId)
@@ -329,4 +329,4 @@ export const classRouter = createTRPCRouter({
329
329
  .mutation(({ ctx, input }) =>
330
330
  importClass(input.classId, ctx.user?.id ?? "", input.year, input.classData)
331
331
  ),
332
- });
332
+ });
@@ -38,8 +38,9 @@ export const labChatRouter = createTRPCRouter({
38
38
  .input(
39
39
  z.object({
40
40
  labChatId: z.string(),
41
- content: z.string().min(1).max(4000),
41
+ content: z.string().max(4000),
42
42
  mentionedUserIds: z.array(z.string()).optional(),
43
+ fileIds: z.array(z.string()).max(10).optional(),
43
44
  })
44
45
  )
45
46
  .mutation(({ ctx, input }) =>
@@ -47,6 +48,7 @@ export const labChatRouter = createTRPCRouter({
47
48
  labChatId: input.labChatId,
48
49
  content: input.content,
49
50
  mentionedUserIds: input.mentionedUserIds,
51
+ fileIds: input.fileIds,
50
52
  })
51
53
  ),
52
54
 
@@ -5,6 +5,8 @@ import {
5
5
  sendMessage,
6
6
  updateMessage,
7
7
  deleteMessage,
8
+ getMessageUploadUrls,
9
+ confirmMessageUpload,
8
10
  markAsRead,
9
11
  markMentionsAsRead,
10
12
  markSuggestionCreated,
@@ -32,8 +34,9 @@ export const messageRouter = createTRPCRouter({
32
34
  .input(
33
35
  z.object({
34
36
  conversationId: z.string(),
35
- content: z.string().min(1).max(4000),
37
+ content: z.string().max(4000),
36
38
  mentionedUserIds: z.array(z.string()).optional(),
39
+ fileIds: z.array(z.string()).max(10).optional(),
37
40
  })
38
41
  )
39
42
  .mutation(({ ctx, input }) =>
@@ -41,9 +44,43 @@ export const messageRouter = createTRPCRouter({
41
44
  conversationId: input.conversationId,
42
45
  content: input.content,
43
46
  mentionedUserIds: input.mentionedUserIds,
47
+ fileIds: input.fileIds,
44
48
  })
45
49
  ),
46
50
 
51
+ getUploadUrls: protectedProcedure
52
+ .input(
53
+ z.object({
54
+ conversationId: z.string(),
55
+ files: z
56
+ .array(
57
+ z.object({
58
+ name: z.string().min(1),
59
+ type: z.string().min(1),
60
+ size: z.number().int().nonnegative(),
61
+ })
62
+ )
63
+ .min(1)
64
+ .max(10),
65
+ })
66
+ )
67
+ .mutation(({ ctx, input }) =>
68
+ getMessageUploadUrls(ctx.user!.id, input)
69
+ ),
70
+
71
+ confirmUpload: protectedProcedure
72
+ .input(
73
+ z.object({
74
+ conversationId: z.string(),
75
+ fileId: z.string(),
76
+ uploadSuccess: z.boolean(),
77
+ errorMessage: z.string().optional(),
78
+ })
79
+ )
80
+ .mutation(({ ctx, input }) =>
81
+ confirmMessageUpload(ctx.user!.id, input)
82
+ ),
83
+
47
84
  update: protectedProcedure
48
85
  .input(
49
86
  z.object({
@@ -21,8 +21,9 @@ export const newtonChatRouter = createTRPCRouter({
21
21
  .input(
22
22
  z.object({
23
23
  newtonChatId: z.string(),
24
- content: z.string().min(1).max(4000),
24
+ content: z.string().max(4000),
25
25
  mentionedUserIds: z.array(z.string()).optional(),
26
+ fileIds: z.array(z.string()).max(10).optional(),
26
27
  })
27
28
  )
28
29
  .mutation(({ ctx, input }) =>
@@ -30,7 +31,8 @@ export const newtonChatRouter = createTRPCRouter({
30
31
  ctx.user!.id,
31
32
  input.newtonChatId,
32
33
  input.content,
33
- input.mentionedUserIds ?? []
34
+ input.mentionedUserIds ?? [],
35
+ input.fileIds ?? []
34
36
  )
35
37
  ),
36
38
  });
@@ -15,7 +15,7 @@ export const notificationRouter = createTRPCRouter({
15
15
 
16
16
  get: protectedProcedure
17
17
  .input(z.object({ id: z.string() }))
18
- .query(({ input }) => getNotification(input.id)),
18
+ .query(({ ctx, input }) => getNotification(ctx.user!.id, input.id)),
19
19
 
20
20
  sendTo: protectedProcedure
21
21
  .input(
@@ -39,5 +39,5 @@ export const notificationRouter = createTRPCRouter({
39
39
 
40
40
  markAsRead: protectedProcedure
41
41
  .input(z.object({ id: z.string() }))
42
- .mutation(({ input }) => markAsRead(input.id)),
42
+ .mutation(({ ctx, input }) => markAsRead(ctx.user!.id, input.id)),
43
43
  });