@studious-lms/server 1.4.3 → 1.4.5

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 (80) hide show
  1. package/.vscode/settings.json +3 -0
  2. package/dist/index.js +4 -18
  3. package/dist/index.js.map +1 -1
  4. package/dist/models/conversation.d.ts +22 -0
  5. package/dist/models/conversation.d.ts.map +1 -1
  6. package/dist/models/conversation.js +13 -2
  7. package/dist/models/conversation.js.map +1 -1
  8. package/dist/models/labChat.d.ts +3 -0
  9. package/dist/models/labChat.d.ts.map +1 -1
  10. package/dist/models/message.d.ts +2 -0
  11. package/dist/models/message.d.ts.map +1 -1
  12. package/dist/models/newtonChat.d.ts +1 -0
  13. package/dist/models/newtonChat.d.ts.map +1 -1
  14. package/dist/models/worksheet.d.ts +1 -0
  15. package/dist/models/worksheet.d.ts.map +1 -1
  16. package/dist/models/worksheet.js +2 -2
  17. package/dist/models/worksheet.js.map +1 -1
  18. package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
  19. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
  20. package/dist/pipelines/pdfToWorksheet.js +155 -0
  21. package/dist/pipelines/pdfToWorksheet.js.map +1 -0
  22. package/dist/routers/_app.d.ts +252 -0
  23. package/dist/routers/_app.d.ts.map +1 -1
  24. package/dist/routers/_app.js +4 -2
  25. package/dist/routers/_app.js.map +1 -1
  26. package/dist/routers/conversation.d.ts +14 -0
  27. package/dist/routers/conversation.d.ts.map +1 -1
  28. package/dist/routers/conversation.js +6 -3
  29. package/dist/routers/conversation.js.map +1 -1
  30. package/dist/routers/labChat.d.ts +2 -0
  31. package/dist/routers/labChat.d.ts.map +1 -1
  32. package/dist/routers/search.d.ts +41 -0
  33. package/dist/routers/search.d.ts.map +1 -0
  34. package/dist/routers/search.js +15 -0
  35. package/dist/routers/search.js.map +1 -0
  36. package/dist/routers/worksheet.d.ts +71 -0
  37. package/dist/routers/worksheet.d.ts.map +1 -1
  38. package/dist/routers/worksheet.js +110 -3
  39. package/dist/routers/worksheet.js.map +1 -1
  40. package/dist/services/conversation.d.ts +8 -0
  41. package/dist/services/conversation.d.ts.map +1 -1
  42. package/dist/services/conversation.js +24 -6
  43. package/dist/services/conversation.js.map +1 -1
  44. package/dist/services/labChat.d.ts +2 -0
  45. package/dist/services/labChat.d.ts.map +1 -1
  46. package/dist/services/message.d.ts.map +1 -1
  47. package/dist/services/message.js +9 -2
  48. package/dist/services/message.js.map +1 -1
  49. package/dist/services/search.d.ts +21 -0
  50. package/dist/services/search.d.ts.map +1 -0
  51. package/dist/services/search.js +352 -0
  52. package/dist/services/search.js.map +1 -0
  53. package/dist/services/worksheet.d.ts +21 -0
  54. package/dist/services/worksheet.d.ts.map +1 -1
  55. package/dist/services/worksheet.js +50 -2
  56. package/dist/services/worksheet.js.map +1 -1
  57. package/dist/utils/inference.d.ts +2 -1
  58. package/dist/utils/inference.d.ts.map +1 -1
  59. package/dist/utils/inference.js +53 -5
  60. package/dist/utils/inference.js.map +1 -1
  61. package/package.json +1 -1
  62. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
  63. package/prisma/schema.prisma +1 -0
  64. package/src/index.ts +4 -19
  65. package/src/models/conversation.ts +12 -0
  66. package/src/models/worksheet.ts +1 -0
  67. package/src/pipelines/pdfToWorksheet.ts +177 -0
  68. package/src/routers/_app.ts +2 -0
  69. package/src/routers/conversation.ts +7 -0
  70. package/src/routers/search.ts +14 -0
  71. package/src/routers/worksheet.ts +143 -0
  72. package/src/services/conversation.ts +27 -3
  73. package/src/services/message.ts +8 -0
  74. package/src/services/search.ts +397 -0
  75. package/src/services/worksheet.ts +61 -0
  76. package/src/utils/inference.ts +77 -7
  77. package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
  78. package/tests/routers/conversation.test.ts +114 -2
  79. package/tests/routers/search.test.ts +109 -0
  80. package/tests/utils/inference.test.ts +37 -0
@@ -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
+ }
@@ -21,6 +21,7 @@ import { marketingRouter } from "./marketing.js";
21
21
  import { worksheetRouter } from "./worksheet.js";
22
22
  import { commentRouter } from "./comment.js";
23
23
  import { studentProgressRouter } from "./studentProgress.js";
24
+ import { searchRouter } from "./search.js";
24
25
 
25
26
  export const appRouter = createTRPCRouter({
26
27
  class: classRouter,
@@ -43,6 +44,7 @@ export const appRouter = createTRPCRouter({
43
44
  worksheet: worksheetRouter,
44
45
  comment: commentRouter,
45
46
  studentProgress: studentProgressRouter,
47
+ search: searchRouter,
46
48
  });
47
49
 
48
50
  // Export type router type definition
@@ -5,6 +5,7 @@ import {
5
5
  createConversationRecord,
6
6
  getConversation,
7
7
  addMemberToConversation,
8
+ hideConversation,
8
9
  removeMemberFromConversation,
9
10
  } from "../services/conversation.js";
10
11
 
@@ -36,6 +37,12 @@ export const conversationRouter = createTRPCRouter({
36
37
  getConversation(ctx.user!.id, input.conversationId)
37
38
  ),
38
39
 
40
+ hide: protectedProcedure
41
+ .input(z.object({ conversationId: z.string() }))
42
+ .mutation(({ ctx, input }) =>
43
+ hideConversation(ctx.user!.id, input.conversationId)
44
+ ),
45
+
39
46
  addMember: protectedProcedure
40
47
  .input(
41
48
  z.object({
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
+ import { searchGlobalClasses } from "../services/search.js";
4
+
5
+ export const searchRouter = createTRPCRouter({
6
+ global: protectedProcedure
7
+ .input(
8
+ z.object({
9
+ query: z.string().max(80).optional(),
10
+ limit: z.number().int().min(1).max(30).optional(),
11
+ })
12
+ )
13
+ .query(({ ctx, input }) => searchGlobalClasses(ctx.user!.id, input)),
14
+ });
@@ -1,4 +1,10 @@
1
1
  import { TRPCError } from "@trpc/server";
2
+ import { prisma } from "../lib/prisma.js";
3
+ import {
4
+ confirmDirectUpload,
5
+ createDirectUploadFiles,
6
+ updateUploadProgress,
7
+ } from "../lib/fileUpload.js";
2
8
  import {
3
9
  createTRPCRouter,
4
10
  protectedClassMemberProcedure,
@@ -13,6 +19,8 @@ import {
13
19
  updateWorksheetRecord,
14
20
  deleteWorksheetRecord,
15
21
  createWorksheetRecord,
22
+ createWorksheetFromPdf,
23
+ createWorksheetFromUploadedPdf,
16
24
  addQuestionToWorksheet,
17
25
  reorderWorksheetQuestions,
18
26
  updateWorksheetQuestionRecord,
@@ -25,6 +33,40 @@ import {
25
33
  addCommentToResponse,
26
34
  } from "../services/worksheet.js";
27
35
 
36
+ const directFileSchema = z.object({
37
+ name: z.string(),
38
+ type: z.string(),
39
+ size: z.number(),
40
+ });
41
+
42
+ const pdfFileSchema = directFileSchema.superRefine((file, ctx) => {
43
+ const isPdf =
44
+ file.type === "application/pdf" && file.name.toLowerCase().endsWith(".pdf");
45
+ if (!isPdf) {
46
+ ctx.addIssue({
47
+ code: z.ZodIssueCode.custom,
48
+ message: "Only PDF files are supported",
49
+ });
50
+ }
51
+ });
52
+
53
+ const getWorksheetUploadUrlsSchema = z.object({
54
+ classId: z.string(),
55
+ files: z.array(pdfFileSchema),
56
+ });
57
+
58
+ const confirmWorksheetUploadSchema = z.object({
59
+ classId: z.string(),
60
+ fileId: z.string(),
61
+ uploadSuccess: z.boolean(),
62
+ errorMessage: z.string().optional(),
63
+ });
64
+
65
+ const updateUploadProgressSchema = z.object({
66
+ fileId: z.string(),
67
+ progress: z.number().min(0).max(100),
68
+ });
69
+
28
70
  export const worksheetRouter = createTRPCRouter({
29
71
  getWorksheet: protectedProcedure
30
72
  .input(z.object({ worksheetId: z.string() }))
@@ -66,6 +108,75 @@ export const worksheetRouter = createTRPCRouter({
66
108
  createWorksheetRecord(input.classId, input.name)
67
109
  ),
68
110
 
111
+ createFromPdf: protectedTeacherProcedure
112
+ .input(z.object({ classId: z.string(), url: z.string().url() }))
113
+ .mutation(({ input }) => createWorksheetFromPdf(input.classId, input.url)),
114
+
115
+ getWorksheetUploadUrls: protectedTeacherProcedure
116
+ .input(getWorksheetUploadUrlsSchema)
117
+ .mutation(async ({ ctx, input }) => {
118
+ if (!ctx.user) {
119
+ throw new TRPCError({
120
+ code: "UNAUTHORIZED",
121
+ message: "You must be logged in to upload files",
122
+ });
123
+ }
124
+
125
+ const uploadFiles = await createDirectUploadFiles(
126
+ input.files,
127
+ ctx.user.id
128
+ );
129
+
130
+ return {
131
+ success: true,
132
+ uploadFiles,
133
+ };
134
+ }),
135
+
136
+ confirmWorksheetUpload: protectedTeacherProcedure
137
+ .input(confirmWorksheetUploadSchema)
138
+ .mutation(async ({ ctx, input }) => {
139
+ if (!ctx.user) {
140
+ throw new TRPCError({
141
+ code: "UNAUTHORIZED",
142
+ message: "You must be logged in",
143
+ });
144
+ }
145
+
146
+ const file = await prisma.file.findFirst({
147
+ where: {
148
+ id: input.fileId,
149
+ userId: ctx.user.id,
150
+ },
151
+ });
152
+
153
+ if (!file) {
154
+ throw new TRPCError({
155
+ code: "NOT_FOUND",
156
+ message: "File not found or you don't have permission",
157
+ });
158
+ }
159
+
160
+ await confirmDirectUpload(
161
+ input.fileId,
162
+ input.uploadSuccess,
163
+ input.errorMessage
164
+ );
165
+
166
+ return {
167
+ success: true,
168
+ message: input.uploadSuccess
169
+ ? "Upload confirmed successfully"
170
+ : "Upload failed",
171
+ };
172
+ }),
173
+
174
+ createFromUploadedPdf: protectedTeacherProcedure
175
+ .input(z.object({ classId: z.string(), fileId: z.string() }))
176
+ .mutation(({ ctx, input }) =>
177
+ createWorksheetFromUploadedPdf(input.classId, input.fileId, ctx.user!.id)
178
+ ),
179
+
69
180
  addQuestion: protectedProcedure
70
181
  .input(
71
182
  z.object({
@@ -261,4 +372,36 @@ export const worksheetRouter = createTRPCRouter({
261
372
  ctx.user!.id
262
373
  )
263
374
  ),
375
+
376
+ updateUploadProgress: protectedProcedure
377
+ .input(updateUploadProgressSchema)
378
+ .mutation(async ({ ctx, input }) => {
379
+ if (!ctx.user) {
380
+ throw new TRPCError({
381
+ code: "UNAUTHORIZED",
382
+ message: "You must be logged in",
383
+ });
384
+ }
385
+
386
+ const file = await prisma.file.findFirst({
387
+ where: {
388
+ id: input.fileId,
389
+ userId: ctx.user.id,
390
+ },
391
+ });
392
+
393
+ if (!file) {
394
+ throw new TRPCError({
395
+ code: "NOT_FOUND",
396
+ message: "File not found or you don't have permission",
397
+ });
398
+ }
399
+
400
+ await updateUploadProgress(input.fileId, input.progress);
401
+
402
+ return {
403
+ success: true,
404
+ progress: input.progress,
405
+ };
406
+ }),
264
407
  });
@@ -13,15 +13,25 @@ import {
13
13
  createConversation,
14
14
  findConversationByIdAndMember,
15
15
  createConversationMember,
16
+ hideConversationMember,
16
17
  deleteConversationMember,
17
18
  } from "../models/conversation.js";
18
19
 
19
20
  /** List user's conversations with unread and mention counts. */
20
21
  export async function listConversations(userId: string) {
21
22
  const conversations = await findConversationsByUserId(userId);
23
+ const visibleConversations = conversations.filter((conversation) => {
24
+ const userMembership = conversation.members.find((member) => member.userId === userId);
25
+
26
+ if (!userMembership?.hiddenAt) {
27
+ return true;
28
+ }
29
+
30
+ return conversation.updatedAt > userMembership.hiddenAt;
31
+ });
22
32
 
23
33
  const conversationsWithUnread = await Promise.all(
24
- conversations.map(async (conversation) => {
34
+ visibleConversations.map(async (conversation) => {
25
35
  const userMembership = conversation.members.find(
26
36
  (m) => m.userId === userId
27
37
  );
@@ -65,6 +75,20 @@ export async function listConversations(userId: string) {
65
75
  return conversationsWithUnread;
66
76
  }
67
77
 
78
+ export async function hideConversation(userId: string, conversationId: string) {
79
+ const conversation = await findConversationByIdAndMember(conversationId, userId);
80
+ if (!conversation) {
81
+ throw new TRPCError({
82
+ code: "FORBIDDEN",
83
+ message: "Not a member of this conversation",
84
+ });
85
+ }
86
+
87
+ await hideConversationMember(userId, conversationId);
88
+
89
+ return { success: true };
90
+ }
91
+
68
92
  /** Create a DM or group conversation with the given members. */
69
93
  export async function createConversationRecord(
70
94
  userId: string,
@@ -87,11 +111,11 @@ export async function createConversationRecord(
87
111
  }
88
112
 
89
113
  if (type === "DM") {
90
- const targetUser = await findUserByUsername(memberIds[0]);
114
+ const [targetUser] = await findUsersByIdsOrUsernames([memberIds[0]]);
91
115
  if (!targetUser) {
92
116
  throw new TRPCError({
93
117
  code: "BAD_REQUEST",
94
- message: `User "${memberIds[0]}" not found`,
118
+ message: `User not found`,
95
119
  });
96
120
  }
97
121
 
@@ -259,6 +259,14 @@ export async function sendMessage(
259
259
  });
260
260
  }
261
261
 
262
+ await tx.conversationMember.updateMany({
263
+ where: {
264
+ conversationId,
265
+ hiddenAt: { not: null },
266
+ },
267
+ data: { hiddenAt: null },
268
+ });
269
+
262
270
  await tx.conversation.update({
263
271
  where: { id: conversationId },
264
272
  data: { updatedAt: new Date() },