@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
@@ -2,9 +2,14 @@
2
2
  * Message service – send, update, delete messages; mark as read; track mentions.
3
3
  * Broadcasts real-time updates via Pusher.
4
4
  */
5
+ import type { Prisma } from "@prisma/client";
5
6
  import { TRPCError } from "@trpc/server";
6
7
  import { prisma } from "../lib/prisma.js";
7
8
  import { chatChannel, pusher } from "../lib/pusher.js";
9
+ import {
10
+ confirmDirectUpload,
11
+ createDirectUploadFiles,
12
+ } from "../lib/fileUpload.js";
8
13
  import { logger } from "../utils/logger.js";
9
14
  import {
10
15
  findConversationMembership,
@@ -16,6 +21,126 @@ import {
16
21
  countUnreadMentions,
17
22
  } from "../models/message.js";
18
23
 
24
+ export function ensureMessageHasContentOrAttachments(
25
+ content: string,
26
+ fileIds: string[] = []
27
+ ) {
28
+ if (!content.trim() && fileIds.length === 0) {
29
+ throw new TRPCError({
30
+ code: "BAD_REQUEST",
31
+ message: "Message must include content or attachments",
32
+ });
33
+ }
34
+ }
35
+
36
+ export async function claimMessageAttachments(
37
+ tx: Prisma.TransactionClient,
38
+ userId: string,
39
+ conversationId: string,
40
+ messageId: string,
41
+ fileIds: string[] = []
42
+ ) {
43
+ const uniqueFileIds = [...new Set(fileIds)];
44
+
45
+ if (uniqueFileIds.length === 0) {
46
+ return;
47
+ }
48
+
49
+ const { count } = await tx.file.updateMany({
50
+ where: {
51
+ id: { in: uniqueFileIds },
52
+ userId,
53
+ conversationId,
54
+ uploadStatus: "COMPLETED",
55
+ messageId: null,
56
+ },
57
+ data: {
58
+ messageId,
59
+ },
60
+ });
61
+
62
+ if (count !== uniqueFileIds.length) {
63
+ throw new TRPCError({
64
+ code: "BAD_REQUEST",
65
+ message: "Some attachments are missing, unfinished, or unavailable",
66
+ });
67
+ }
68
+ }
69
+
70
+ export async function getMessageUploadUrls(
71
+ userId: string,
72
+ input: {
73
+ conversationId: string;
74
+ files: { name: string; type: string; size: number }[];
75
+ }
76
+ ) {
77
+ const membership = await findConversationMembership(input.conversationId, userId);
78
+ if (!membership) {
79
+ throw new TRPCError({
80
+ code: "FORBIDDEN",
81
+ message: "Not a member of this conversation",
82
+ });
83
+ }
84
+
85
+ return createDirectUploadFiles(
86
+ input.files,
87
+ userId,
88
+ undefined,
89
+ undefined,
90
+ undefined,
91
+ undefined,
92
+ input.conversationId
93
+ );
94
+ }
95
+
96
+ export async function confirmMessageUpload(
97
+ userId: string,
98
+ input: {
99
+ conversationId: string;
100
+ fileId: string;
101
+ uploadSuccess: boolean;
102
+ errorMessage?: string;
103
+ }
104
+ ) {
105
+ const membership = await findConversationMembership(input.conversationId, userId);
106
+ if (!membership) {
107
+ throw new TRPCError({
108
+ code: "FORBIDDEN",
109
+ message: "Not a member of this conversation",
110
+ });
111
+ }
112
+
113
+ const file = await prisma.file.findFirst({
114
+ where: {
115
+ id: input.fileId,
116
+ userId,
117
+ conversationId: input.conversationId,
118
+ messageId: null,
119
+ },
120
+ select: { id: true },
121
+ });
122
+
123
+ if (!file) {
124
+ throw new TRPCError({
125
+ code: "NOT_FOUND",
126
+ message: "File not found or you do not have permission",
127
+ });
128
+ }
129
+
130
+ await confirmDirectUpload(
131
+ input.fileId,
132
+ input.uploadSuccess,
133
+ input.errorMessage
134
+ );
135
+
136
+ return {
137
+ success: true,
138
+ message: input.uploadSuccess
139
+ ? "Upload confirmed successfully"
140
+ : "Upload failed",
141
+ };
142
+ }
143
+
19
144
  /** List messages in a conversation with cursor-based pagination. */
20
145
  export async function listMessages(
21
146
  userId: string,
@@ -74,9 +199,18 @@ export async function sendMessage(
74
199
  conversationId: string;
75
200
  content: string;
76
201
  mentionedUserIds?: string[];
202
+ fileIds?: string[];
77
203
  }
78
204
  ) {
79
- const { conversationId, content, mentionedUserIds = [] } = input;
205
+ const {
206
+ conversationId,
207
+ content,
208
+ mentionedUserIds = [],
209
+ fileIds = [],
210
+ } = input;
211
+
212
+ ensureMessageHasContentOrAttachments(content, fileIds);
213
+ const normalizedContent = content.trim();
80
214
 
81
215
  const membership = await findConversationMembership(conversationId, userId);
82
216
  if (!membership) {
@@ -102,10 +236,36 @@ export async function sendMessage(
102
236
  const result = await prisma.$transaction(async (tx) => {
103
237
  const message = await tx.message.create({
104
238
  data: {
105
- content,
239
+ content: normalizedContent,
106
240
  senderId: userId,
107
241
  conversationId,
108
242
  },
243
+ });
244
+
245
+ await claimMessageAttachments(
246
+ tx,
247
+ userId,
248
+ conversationId,
249
+ message.id,
250
+ fileIds
251
+ );
252
+
253
+ if (mentionedUserIds.length > 0) {
254
+ await tx.mention.createMany({
255
+ data: mentionedUserIds.map((mentionedUserId) => ({
256
+ messageId: message.id,
257
+ userId: mentionedUserId,
258
+ })),
259
+ });
260
+ }
261
+
262
+ await tx.conversation.update({
263
+ where: { id: conversationId },
264
+ data: { updatedAt: new Date() },
265
+ });
266
+
267
+ return tx.message.findUniqueOrThrow({
268
+ where: { id: message.id },
109
269
  include: {
110
270
  sender: {
111
271
  select: {
@@ -124,22 +284,6 @@ export async function sendMessage(
124
284
  },
125
285
  },
126
286
  });
127
-
128
- if (mentionedUserIds.length > 0) {
129
- await tx.mention.createMany({
130
- data: mentionedUserIds.map((mentionedUserId) => ({
131
- messageId: message.id,
132
- userId: mentionedUserId,
133
- })),
134
- });
135
- }
136
-
137
- await tx.conversation.update({
138
- where: { id: conversationId },
139
- data: { updatedAt: new Date() },
140
- });
141
-
142
- return message;
143
287
  });
144
288
 
145
289
  try {
@@ -173,6 +317,7 @@ export async function sendMessage(
173
317
  conversationId: result.conversationId,
174
318
  createdAt: result.createdAt,
175
319
  sender: result.sender,
320
+ attachments: result.attachments ?? [],
176
321
  mentionedUserIds,
177
322
  };
178
323
  }
@@ -7,6 +7,10 @@ import { logger } from "../utils/logger.js";
7
7
  import { chatChannel, pusher } from "../lib/pusher.js";
8
8
  import { isAIUser } from "../utils/aiUser.js";
9
9
  import { generateAndSendNewtonIntroduction, generateAndSendNewtonResponse } from "../pipelines/aiNewtonChat.js";
10
+ import {
11
+ claimMessageAttachments,
12
+ ensureMessageHasContentOrAttachments,
13
+ } from "./message.js";
10
14
  import {
11
15
  findClassWithStudent,
12
16
  findSubmissionByAssignmentAndStudent,
@@ -118,7 +122,8 @@ export async function postToNewtonChat(
118
122
  userId: string,
119
123
  newtonChatId: string,
120
124
  content: string,
121
- mentionedUserIds: string[] = []
125
+ mentionedUserIds: string[] = [],
126
+ fileIds: string[] = []
122
127
  ) {
123
128
  const newtonChat = await findNewtonChatByIdAndMember(newtonChatId, userId);
124
129
  if (!newtonChat) {
@@ -141,29 +146,33 @@ export async function postToNewtonChat(
141
146
  }
142
147
  }
143
148
 
149
+ ensureMessageHasContentOrAttachments(content, fileIds);
150
+ const normalizedContent = content.trim();
151
+
152
+ if (!normalizedContent) {
153
+ throw new TRPCError({
154
+ code: "BAD_REQUEST",
155
+ message: "Newton chat messages must include text content",
156
+ });
157
+ }
158
+
144
159
  const result = await prisma.$transaction(async (tx) => {
145
160
  const message = await tx.message.create({
146
161
  data: {
147
- content,
162
+ content: normalizedContent,
148
163
  senderId: userId,
149
164
  conversationId: newtonChat.conversationId,
150
165
  },
151
- include: {
152
- sender: {
153
- select: {
154
- id: true,
155
- username: true,
156
- profile: {
157
- select: {
158
- displayName: true,
159
- profilePicture: true,
160
- },
161
- },
162
- },
163
- },
164
- },
165
166
  });
166
167
 
168
+ await claimMessageAttachments(
169
+ tx,
170
+ userId,
171
+ newtonChat.conversationId,
172
+ message.id,
173
+ fileIds
174
+ );
175
+
167
176
  if (mentionedUserIds.length > 0) {
168
177
  await tx.mention.createMany({
169
178
  data: mentionedUserIds.map((mentionedUserId) => ({
@@ -178,7 +187,26 @@ export async function postToNewtonChat(
178
187
  data: { updatedAt: new Date() },
179
188
  });
180
189
 
181
- return message;
190
+ return tx.message.findUniqueOrThrow({
191
+ where: { id: message.id },
192
+ include: {
193
+ sender: {
194
+ select: {
195
+ id: true,
196
+ username: true,
197
+ profile: {
198
+ select: {
199
+ displayName: true,
200
+ profilePicture: true,
201
+ },
202
+ },
203
+ },
204
+ },
205
+ attachments: {
206
+ select: { id: true, name: true, type: true },
207
+ },
208
+ },
209
+ });
182
210
  });
183
211
 
184
212
  try {
@@ -192,7 +220,9 @@ export async function postToNewtonChat(
192
220
  conversationId: result.conversationId,
193
221
  createdAt: result.createdAt,
194
222
  sender: result.sender,
223
+ attachments: result.attachments ?? [],
195
224
  mentionedUserIds,
225
+ meta: {},
196
226
  }
197
227
  );
198
228
  } catch (error) {
@@ -202,7 +232,7 @@ export async function postToNewtonChat(
202
232
  if (!isAIUser(userId)) {
203
233
  generateAndSendNewtonResponse(
204
234
  newtonChatId,
205
- content,
235
+ normalizedContent,
206
236
  newtonChat.conversationId,
207
237
  newtonChat.submission
208
238
  ).catch((error) => {
@@ -217,6 +247,7 @@ export async function postToNewtonChat(
217
247
  conversationId: result.conversationId,
218
248
  createdAt: result.createdAt,
219
249
  sender: result.sender,
250
+ attachments: result.attachments ?? [],
220
251
  mentionedUserIds,
221
252
  };
222
253
  }
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Notification service – list, send, and mark notifications as read.
3
3
  */
4
+ import { TRPCError } from "@trpc/server";
4
5
  import {
5
6
  findNotificationsByReceiverId,
6
- findNotificationById,
7
+ findNotificationByIdForReceiver,
7
8
  createNotification,
8
9
  createManyNotifications,
9
- markNotificationAsRead,
10
+ markNotificationAsReadForReceiver,
10
11
  } from "../models/notification.js";
11
12
 
12
13
  /** List notifications for a user. */
@@ -14,9 +15,18 @@ export async function listNotifications(userId: string) {
14
15
  return findNotificationsByReceiverId(userId);
15
16
  }
16
17
 
17
- /** Get a single notification by ID. */
18
- export async function getNotification(id: string) {
19
- return findNotificationById(id);
18
+ /** Get a single notification by ID for its receiver. */
19
+ export async function getNotification(userId: string, id: string) {
20
+ const notification = await findNotificationByIdForReceiver(id, userId);
21
+
22
+ if (!notification) {
23
+ throw new TRPCError({
24
+ code: "NOT_FOUND",
25
+ message: "Notification not found",
26
+ });
27
+ }
28
+
29
+ return notification;
20
30
  }
21
31
 
22
32
  /** Send a notification to one user. */
@@ -44,7 +54,16 @@ export async function sendToMultiple(input: {
44
54
  return result;
45
55
  }
46
56
 
47
- /** Mark a notification as read. */
48
- export async function markAsRead(id: string) {
49
- return markNotificationAsRead(id);
57
+ /** Mark a notification as read for its receiver. */
58
+ export async function markAsRead(userId: string, id: string) {
59
+ const notification = await markNotificationAsReadForReceiver(id, userId);
60
+
61
+ if (!notification) {
62
+ throw new TRPCError({
63
+ code: "NOT_FOUND",
64
+ message: "Notification not found",
65
+ });
66
+ }
67
+
68
+ return notification;
50
69
  }
@@ -635,7 +635,10 @@ export async function chatAboutStudentProgress(
635
635
  ];
636
636
 
637
637
  try {
638
- const response = await inference<string>(messages);
638
+ const response = await inference<string>(messages, undefined, {
639
+ reason: "student_progress_chat",
640
+ metadata: { viewerId, classId: input.classId, studentId: input.studentId },
641
+ });
639
642
  if (typeof response !== "string" || response.trim().length === 0) {
640
643
  throw new Error("Student progress chat returned an empty response");
641
644
  }