@studious-lms/server 1.4.2 → 1.4.3

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 (145) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +67 -28
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/fileUpload.d.ts +10 -2
  6. package/dist/lib/fileUpload.d.ts.map +1 -1
  7. package/dist/lib/fileUpload.js +38 -6
  8. package/dist/lib/fileUpload.js.map +1 -1
  9. package/dist/middleware/auth.d.ts.map +1 -1
  10. package/dist/middleware/auth.js +18 -5
  11. package/dist/middleware/auth.js.map +1 -1
  12. package/dist/models/auth.d.ts +19 -14
  13. package/dist/models/auth.d.ts.map +1 -1
  14. package/dist/models/auth.js +53 -30
  15. package/dist/models/auth.js.map +1 -1
  16. package/dist/models/class.d.ts +3 -1
  17. package/dist/models/class.d.ts.map +1 -1
  18. package/dist/models/class.js +16 -4
  19. package/dist/models/class.js.map +1 -1
  20. package/dist/models/notification.d.ts +11 -10
  21. package/dist/models/notification.d.ts.map +1 -1
  22. package/dist/models/notification.js +13 -9
  23. package/dist/models/notification.js.map +1 -1
  24. package/dist/models/worksheet.d.ts +378 -7
  25. package/dist/models/worksheet.d.ts.map +1 -1
  26. package/dist/models/worksheet.js +168 -2
  27. package/dist/models/worksheet.js.map +1 -1
  28. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  29. package/dist/pipelines/aiLabChat.js +11 -3
  30. package/dist/pipelines/aiLabChat.js.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  32. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.js +25 -14
  34. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +10 -3
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/routers/_app.d.ts +124 -18
  39. package/dist/routers/_app.d.ts.map +1 -1
  40. package/dist/routers/class.d.ts +2 -1
  41. package/dist/routers/class.d.ts.map +1 -1
  42. package/dist/routers/class.js +3 -3
  43. package/dist/routers/class.js.map +1 -1
  44. package/dist/routers/labChat.d.ts +6 -0
  45. package/dist/routers/labChat.d.ts.map +1 -1
  46. package/dist/routers/labChat.js +5 -3
  47. package/dist/routers/labChat.js.map +1 -1
  48. package/dist/routers/message.d.ts +31 -0
  49. package/dist/routers/message.d.ts.map +1 -1
  50. package/dist/routers/message.js +27 -4
  51. package/dist/routers/message.js.map +1 -1
  52. package/dist/routers/newtonChat.d.ts +6 -0
  53. package/dist/routers/newtonChat.d.ts.map +1 -1
  54. package/dist/routers/newtonChat.js +5 -4
  55. package/dist/routers/newtonChat.js.map +1 -1
  56. package/dist/routers/notifications.d.ts +9 -2
  57. package/dist/routers/notifications.d.ts.map +1 -1
  58. package/dist/routers/notifications.js +4 -4
  59. package/dist/routers/notifications.js.map +1 -1
  60. package/dist/routers/worksheet.d.ts +8 -6
  61. package/dist/routers/worksheet.d.ts.map +1 -1
  62. package/dist/routers/worksheet.js +18 -16
  63. package/dist/routers/worksheet.js.map +1 -1
  64. package/dist/services/auth.d.ts +1 -1
  65. package/dist/services/auth.d.ts.map +1 -1
  66. package/dist/services/auth.js +19 -12
  67. package/dist/services/auth.js.map +1 -1
  68. package/dist/services/class.d.ts +1 -1
  69. package/dist/services/class.d.ts.map +1 -1
  70. package/dist/services/class.js +6 -3
  71. package/dist/services/class.js.map +1 -1
  72. package/dist/services/file.d.ts.map +1 -1
  73. package/dist/services/file.js +7 -2
  74. package/dist/services/file.js.map +1 -1
  75. package/dist/services/labChat.d.ts +6 -0
  76. package/dist/services/labChat.d.ts.map +1 -1
  77. package/dist/services/labChat.js +30 -18
  78. package/dist/services/labChat.js.map +1 -1
  79. package/dist/services/message.d.ts +30 -0
  80. package/dist/services/message.d.ts.map +1 -1
  81. package/dist/services/message.js +97 -21
  82. package/dist/services/message.js.map +1 -1
  83. package/dist/services/newtonChat.d.ts +6 -1
  84. package/dist/services/newtonChat.d.ts.map +1 -1
  85. package/dist/services/newtonChat.js +36 -18
  86. package/dist/services/newtonChat.js.map +1 -1
  87. package/dist/services/notification.d.ts +12 -5
  88. package/dist/services/notification.d.ts.map +1 -1
  89. package/dist/services/notification.js +24 -9
  90. package/dist/services/notification.js.map +1 -1
  91. package/dist/services/studentProgress.d.ts.map +1 -1
  92. package/dist/services/studentProgress.js +6 -3
  93. package/dist/services/studentProgress.js.map +1 -1
  94. package/dist/services/worksheet.d.ts +15 -15
  95. package/dist/services/worksheet.d.ts.map +1 -1
  96. package/dist/services/worksheet.js +138 -54
  97. package/dist/services/worksheet.js.map +1 -1
  98. package/dist/trpc.d.ts.map +1 -1
  99. package/dist/trpc.js +11 -17
  100. package/dist/trpc.js.map +1 -1
  101. package/dist/utils/inference.d.ts +9 -1
  102. package/dist/utils/inference.d.ts.map +1 -1
  103. package/dist/utils/inference.js +138 -50
  104. package/dist/utils/inference.js.map +1 -1
  105. package/package.json +1 -1
  106. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  107. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  108. package/prisma/schema.prisma +27 -1
  109. package/scripts/test-pre-push.ts +6 -2
  110. package/src/index.ts +80 -28
  111. package/src/lib/fileUpload.ts +55 -5
  112. package/src/middleware/auth.ts +19 -4
  113. package/src/models/auth.ts +59 -29
  114. package/src/models/class.ts +15 -4
  115. package/src/models/notification.ts +13 -7
  116. package/src/models/worksheet.ts +202 -0
  117. package/src/pipelines/aiLabChat.ts +9 -1
  118. package/src/pipelines/aiNewtonChat.ts +24 -14
  119. package/src/pipelines/gradeWorksheet.ts +8 -0
  120. package/src/routers/class.ts +2 -2
  121. package/src/routers/labChat.ts +3 -1
  122. package/src/routers/message.ts +38 -1
  123. package/src/routers/newtonChat.ts +4 -2
  124. package/src/routers/notifications.ts +2 -2
  125. package/src/routers/worksheet.ts +31 -19
  126. package/src/services/auth.ts +26 -12
  127. package/src/services/class.ts +4 -1
  128. package/src/services/file.ts +6 -0
  129. package/src/services/labChat.ts +42 -18
  130. package/src/services/message.ts +163 -18
  131. package/src/services/newtonChat.ts +49 -18
  132. package/src/services/notification.ts +27 -8
  133. package/src/services/studentProgress.ts +4 -1
  134. package/src/services/worksheet.ts +191 -51
  135. package/src/trpc.ts +12 -18
  136. package/src/utils/inference.ts +209 -55
  137. package/tests/globalSetup.ts +13 -4
  138. package/tests/routers/auth.test.ts +120 -17
  139. package/tests/routers/class.test.ts +7 -1
  140. package/tests/routers/message.test.ts +236 -9
  141. package/tests/routers/newtonChat.test.ts +81 -0
  142. package/tests/routers/notification.test.ts +12 -0
  143. package/tests/routers/worksheet.test.ts +194 -0
  144. package/tests/setup.ts +22 -5
  145. package/tests/utils/inference.test.ts +218 -0
@@ -2,6 +2,7 @@
2
2
  * Auth service – user registration, login, logout, verification, and password reset.
3
3
  * Handles session creation, Redis caching, and email notifications.
4
4
  */
5
+ import { SessionType } from "@prisma/client";
5
6
  import { TRPCError } from "@trpc/server";
6
7
  import { v4 as uuidv4 } from "uuid";
7
8
  import { compare, hash } from "bcryptjs";
@@ -19,13 +20,12 @@ import {
19
20
  deleteSessionsByUserId,
20
21
  deleteShortLivedSessionsByUserId,
21
22
  deleteUserById,
22
- findSessionById,
23
- findSessionByIdWithUser,
23
+ findSessionByIdAndType,
24
+ findSessionByIdWithUserAndType,
24
25
  updateUserVerified,
25
26
  updateUserPassword,
26
27
  deleteSessionById,
27
28
  } from "../models/auth.js";
28
- import { getRedis } from "../lib/redis.js";
29
29
 
30
30
  /** Register a new user, create verification session, and send verification email. */
31
31
  export async function register(input: {
@@ -79,6 +79,7 @@ export async function register(input: {
79
79
  () =>
80
80
  createSession({
81
81
  id: uuidv4(),
82
+ type: SessionType.EMAIL_VERIFICATION,
82
83
  userId: user.id,
83
84
  expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
84
85
  }),
@@ -102,7 +103,7 @@ export async function register(input: {
102
103
  return { user: { id: user.id, username: user.username } };
103
104
  }
104
105
 
105
- /** Authenticate user by username/password, create session, and cache in Redis. */
106
+ /** Authenticate user by username/password, create session. */
106
107
  export async function login(input: { username: string; password: string }) {
107
108
  const { username, password } = input;
108
109
 
@@ -130,15 +131,11 @@ export async function login(input: { username: string; password: string }) {
130
131
 
131
132
  const session = await createSession({
132
133
  id: uuidv4(),
134
+ type: SessionType.AUTH,
133
135
  userId: user.id,
134
136
  expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
135
137
  });
136
138
 
137
- const cachePayload = JSON.stringify({ id: user.id, username: user.username });
138
- const ttlSeconds = 30 * 24 * 60 * 60; // 30 days, match session expiry
139
- const r = getRedis();
140
- if (r) await r.setex(`session:${session.id}`, ttlSeconds, cachePayload);
141
-
142
139
  return {
143
140
  token: session.id,
144
141
  user: { id: user.id, username: user.username },
@@ -177,6 +174,7 @@ export async function resendVerificationEmail(email: string) {
177
174
 
178
175
  const verificationToken = await createSession({
179
176
  id: uuidv4(),
177
+ type: SessionType.EMAIL_VERIFICATION,
180
178
  userId: user.id,
181
179
  expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
182
180
  });
@@ -200,7 +198,10 @@ export async function resendVerificationEmail(email: string) {
200
198
 
201
199
  /** Verify email via token. Marks user as verified and deletes the session. */
202
200
  export async function verify(token: string) {
203
- const session = await findSessionById(token);
201
+ const session = await findSessionByIdAndType(
202
+ token,
203
+ SessionType.EMAIL_VERIFICATION
204
+ );
204
205
  if (!session) {
205
206
  throw new TRPCError({
206
207
  code: "NOT_FOUND",
@@ -209,13 +210,22 @@ export async function verify(token: string) {
209
210
  }
210
211
 
211
212
  if (session.expiresAt && session.expiresAt < new Date()) {
213
+ await deleteSessionById(token);
212
214
  throw new TRPCError({
213
215
  code: "UNAUTHORIZED",
214
216
  message: "Session expired",
215
217
  });
216
218
  }
217
219
 
218
- await updateUserVerified(session.userId!, true);
220
+ if (!session.userId) {
221
+ await deleteSessionById(token);
222
+ throw new TRPCError({
223
+ code: "BAD_REQUEST",
224
+ message: "Invalid session state",
225
+ });
226
+ }
227
+
228
+ await updateUserVerified(session.userId, true);
219
229
  await deleteSessionById(token);
220
230
  return { success: true };
221
231
  }
@@ -232,6 +242,7 @@ export async function requestPasswordReset(email: string) {
232
242
 
233
243
  const resetToken = await createSession({
234
244
  id: uuidv4(),
245
+ type: SessionType.PASSWORD_RESET,
235
246
  userId: user.id,
236
247
  expiresAt: new Date(Date.now() + 1000 * 60 * 60),
237
248
  });
@@ -255,7 +266,10 @@ export async function requestPasswordReset(email: string) {
255
266
 
256
267
  /** Reset password using token from email. Validates token and updates password. */
257
268
  export async function resetPassword(token: string, password: string) {
258
- const session = await findSessionByIdWithUser(token);
269
+ const session = await findSessionByIdWithUserAndType(
270
+ token,
271
+ SessionType.PASSWORD_RESET
272
+ );
259
273
  if (!session || !session.userId) {
260
274
  throw new TRPCError({
261
275
  code: "NOT_FOUND",
@@ -2,6 +2,7 @@
2
2
  * Class service – CRUD for classes, members, grades, mark schemes, grading boundaries,
3
3
  * syllabus, lab drafts, and invite codes.
4
4
  */
5
+ import { SessionType } from "@prisma/client";
5
6
  import { TRPCError } from "@trpc/server";
6
7
  import { prisma } from "../lib/prisma.js";
7
8
  import { generateInviteCode } from "../utils/generateInviteCode.js";
@@ -286,6 +287,7 @@ export async function getInviteCode(classId: string) {
286
287
  data: {
287
288
  id: generateInviteCode(),
288
289
  classId,
290
+ type: SessionType.CLASS_INVITE,
289
291
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
290
292
  },
291
293
  });
@@ -297,13 +299,14 @@ export async function getInviteCode(classId: string) {
297
299
 
298
300
  export async function createInviteCode(classId: string) {
299
301
  await prisma.session.deleteMany({
300
- where: { classId },
302
+ where: { classId, type: SessionType.CLASS_INVITE },
301
303
  });
302
304
 
303
305
  const session = await prisma.session.create({
304
306
  data: {
305
307
  id: generateInviteCode(),
306
308
  classId,
309
+ type: SessionType.CLASS_INVITE,
307
310
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
308
311
  },
309
312
  });
@@ -14,6 +14,7 @@ import {
14
14
  updateFileName,
15
15
  deleteFileById,
16
16
  } from "../models/file.js";
17
+ import { findConversationMembership } from "../models/message.js";
17
18
 
18
19
  /** Generate a signed URL for file download. Validates user has access. */
19
20
  export async function getFileSignedUrl(userId: string, fileId: string) {
@@ -65,6 +66,11 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
65
66
  hasAccess = true;
66
67
  }
67
68
 
69
+ if (file.conversationId) {
70
+ const membership = await findConversationMembership(file.conversationId, userId);
71
+ hasAccess = hasAccess || Boolean(membership);
72
+ }
73
+
68
74
  if (file.folder?.class) {
69
75
  const isTeacher = file.folder.class.teachers.some((t) => t.id === userId);
70
76
  const isStudent = file.folder.class.students.some((s) => s.id === userId);
@@ -20,6 +20,10 @@ import {
20
20
  import { generateAndSendLabIntroduction, generateAndSendLabResponse } from "../pipelines/aiLabChat.js";
21
21
  import { isAIUser } from "../utils/aiUser.js";
22
22
  import { logger } from "../utils/logger.js";
23
+ import {
24
+ claimMessageAttachments,
25
+ ensureMessageHasContentOrAttachments,
26
+ } from "./message.js";
23
27
 
24
28
  /** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
25
29
  export async function createLabChat(
@@ -213,9 +217,10 @@ export async function postToLabChat(
213
217
  labChatId: string;
214
218
  content: string;
215
219
  mentionedUserIds?: string[];
220
+ fileIds?: string[];
216
221
  }
217
222
  ) {
218
- const { labChatId, content, mentionedUserIds = [] } = input;
223
+ const { labChatId, content, mentionedUserIds = [], fileIds = [] } = input;
219
224
 
220
225
  const labChat = await findLabChatForPost(labChatId, userId);
221
226
  if (!labChat) {
@@ -238,30 +243,27 @@ export async function postToLabChat(
238
243
  }
239
244
  }
240
245
 
246
+ ensureMessageHasContentOrAttachments(content, fileIds);
247
+ const normalizedContent = content.trim();
248
+
241
249
  const result = await prisma.$transaction(async (tx) => {
242
250
  const message = await tx.message.create({
243
251
  data: {
244
- content,
252
+ content: normalizedContent,
245
253
  senderId: userId,
246
254
  conversationId: labChat.conversationId,
247
255
  ...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
248
256
  },
249
- include: {
250
- sender: {
251
- select: {
252
- id: true,
253
- username: true,
254
- profile: {
255
- select: {
256
- displayName: true,
257
- profilePicture: true,
258
- },
259
- },
260
- },
261
- },
262
- },
263
257
  });
264
258
 
259
+ await claimMessageAttachments(
260
+ tx,
261
+ userId,
262
+ labChat.conversationId,
263
+ message.id,
264
+ fileIds
265
+ );
266
+
265
267
  if (mentionedUserIds.length > 0) {
266
268
  await tx.mention.createMany({
267
269
  data: mentionedUserIds.map((mentionedUserId) => ({
@@ -276,7 +278,26 @@ export async function postToLabChat(
276
278
  data: { updatedAt: new Date() },
277
279
  });
278
280
 
279
- return message;
281
+ return tx.message.findUniqueOrThrow({
282
+ where: { id: message.id },
283
+ include: {
284
+ sender: {
285
+ select: {
286
+ id: true,
287
+ username: true,
288
+ profile: {
289
+ select: {
290
+ displayName: true,
291
+ profilePicture: true,
292
+ },
293
+ },
294
+ },
295
+ },
296
+ attachments: {
297
+ select: { id: true, name: true, type: true },
298
+ },
299
+ },
300
+ });
280
301
  });
281
302
 
282
303
  try {
@@ -290,7 +311,9 @@ export async function postToLabChat(
290
311
  conversationId: result.conversationId,
291
312
  createdAt: result.createdAt,
292
313
  sender: result.sender,
314
+ attachments: result.attachments ?? [],
293
315
  mentionedUserIds,
316
+ meta: {},
294
317
  }
295
318
  );
296
319
  } catch (error) {
@@ -307,7 +330,7 @@ export async function postToLabChat(
307
330
  } catch (error) {
308
331
  console.error("Failed to broadcast lab response pending:", error);
309
332
  }
310
- generateAndSendLabResponse(labChatId, content, {
333
+ generateAndSendLabResponse(labChatId, normalizedContent, {
311
334
  classId: labChat.classId,
312
335
  messageId: result.id,
313
336
  }).catch((error) =>
@@ -327,6 +350,7 @@ export async function postToLabChat(
327
350
  conversationId: result.conversationId,
328
351
  createdAt: result.createdAt,
329
352
  sender: result.sender,
353
+ attachments: result.attachments ?? [],
330
354
  mentionedUserIds,
331
355
  };
332
356
  }
@@ -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
  }