@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
@@ -1,8 +1,15 @@
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,
5
11
  protectedProcedure,
12
+ protectedTeacherProcedure,
6
13
  } from "../trpc.js";
7
14
  import { z } from "zod";
8
15
  import {
@@ -12,6 +19,8 @@ import {
12
19
  updateWorksheetRecord,
13
20
  deleteWorksheetRecord,
14
21
  createWorksheetRecord,
22
+ createWorksheetFromPdf,
23
+ createWorksheetFromUploadedPdf,
15
24
  addQuestionToWorksheet,
16
25
  reorderWorksheetQuestions,
17
26
  updateWorksheetQuestionRecord,
@@ -24,10 +33,44 @@ import {
24
33
  addCommentToResponse,
25
34
  } from "../services/worksheet.js";
26
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
+
27
70
  export const worksheetRouter = createTRPCRouter({
28
71
  getWorksheet: protectedProcedure
29
72
  .input(z.object({ worksheetId: z.string() }))
30
- .query(({ input }) => getWorksheet(input.worksheetId)),
73
+ .query(({ ctx, input }) => getWorksheet(input.worksheetId, ctx.user!.id)),
31
74
 
32
75
  exists: protectedClassMemberProcedure
33
76
  .input(z.object({ id: z.string() }))
@@ -41,26 +84,99 @@ export const worksheetRouter = createTRPCRouter({
41
84
  return worksheetExists(input.id);
42
85
  }),
43
86
 
44
- listWorksheets: protectedProcedure
87
+ listWorksheets: protectedClassMemberProcedure
45
88
  .input(z.object({ classId: z.string() }))
46
89
  .query(({ input }) => listWorksheets(input.classId)),
47
90
 
48
91
  updateWorksheet: protectedProcedure
49
92
  .input(z.object({ worksheetId: z.string(), name: z.string().optional() }))
50
- .mutation(({ input }) =>
51
- updateWorksheetRecord(input.worksheetId, { name: input.name })
93
+ .mutation(({ ctx, input }) =>
94
+ updateWorksheetRecord(input.worksheetId, ctx.user!.id, {
95
+ name: input.name,
96
+ })
52
97
  ),
53
98
 
54
99
  deleteWorksheet: protectedProcedure
55
100
  .input(z.object({ worksheetId: z.string() }))
56
- .mutation(({ input }) => deleteWorksheetRecord(input.worksheetId)),
101
+ .mutation(({ ctx, input }) =>
102
+ deleteWorksheetRecord(input.worksheetId, ctx.user!.id)
103
+ ),
57
104
 
58
- create: protectedProcedure
105
+ create: protectedTeacherProcedure
59
106
  .input(z.object({ classId: z.string(), name: z.string() }))
60
107
  .mutation(({ input }) =>
61
108
  createWorksheetRecord(input.classId, input.name)
62
109
  ),
63
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
+
64
180
  addQuestion: protectedProcedure
65
181
  .input(
66
182
  z.object({
@@ -80,8 +196,8 @@ export const worksheetRouter = createTRPCRouter({
80
196
  ]),
81
197
  })
82
198
  )
83
- .mutation(({ input }) =>
84
- addQuestionToWorksheet(input.worksheetId, {
199
+ .mutation(({ ctx, input }) =>
200
+ addQuestionToWorksheet(input.worksheetId, ctx.user!.id, {
85
201
  question: input.question,
86
202
  answer: input.answer,
87
203
  points: input.points,
@@ -100,9 +216,10 @@ export const worksheetRouter = createTRPCRouter({
100
216
  targetId: z.string(),
101
217
  })
102
218
  )
103
- .mutation(({ input }) =>
219
+ .mutation(({ ctx, input }) =>
104
220
  reorderWorksheetQuestions(
105
221
  input.worksheetId,
222
+ ctx.user!.id,
106
223
  input.movedId,
107
224
  input.position,
108
225
  input.targetId
@@ -131,10 +248,11 @@ export const worksheetRouter = createTRPCRouter({
131
248
  .optional(),
132
249
  })
133
250
  )
134
- .mutation(({ input }) =>
251
+ .mutation(({ ctx, input }) =>
135
252
  updateWorksheetQuestionRecord(
136
253
  input.worksheetId,
137
254
  input.questionId,
255
+ ctx.user!.id,
138
256
  {
139
257
  question: input.question,
140
258
  answer: input.answer,
@@ -153,8 +271,12 @@ export const worksheetRouter = createTRPCRouter({
153
271
  questionId: z.string(),
154
272
  })
155
273
  )
156
- .mutation(({ input }) =>
157
- deleteWorksheetQuestionRecord(input.worksheetId, input.questionId)
274
+ .mutation(({ ctx, input }) =>
275
+ deleteWorksheetQuestionRecord(
276
+ input.worksheetId,
277
+ input.questionId,
278
+ ctx.user!.id
279
+ )
158
280
  ),
159
281
 
160
282
  getWorksheetSubmission: protectedProcedure
@@ -164,8 +286,8 @@ export const worksheetRouter = createTRPCRouter({
164
286
  submissionId: z.string(),
165
287
  })
166
288
  )
167
- .query(({ input }) =>
168
- getWorksheetSubmission(input.worksheetId, input.submissionId)
289
+ .query(({ ctx, input }) =>
290
+ getWorksheetSubmission(input.worksheetId, input.submissionId, ctx.user!.id)
169
291
  ),
170
292
 
171
293
  answerQuestion: protectedProcedure
@@ -192,8 +314,8 @@ export const worksheetRouter = createTRPCRouter({
192
314
  progressId: z.string(),
193
315
  })
194
316
  )
195
- .mutation(({ input }) =>
196
- cancelGrading(input.worksheetResponseId, input.progressId)
317
+ .mutation(({ ctx, input }) =>
318
+ cancelGrading(input.worksheetResponseId, input.progressId, ctx.user!.id)
197
319
  ),
198
320
 
199
321
  regradeQuestion: protectedProcedure
@@ -203,8 +325,8 @@ export const worksheetRouter = createTRPCRouter({
203
325
  progressId: z.string(),
204
326
  })
205
327
  )
206
- .mutation(({ input }) =>
207
- regradeQuestion(input.worksheetResponseId, input.progressId)
328
+ .mutation(({ ctx, input }) =>
329
+ regradeQuestion(input.worksheetResponseId, input.progressId, ctx.user!.id)
208
330
  ),
209
331
 
210
332
  gradeAnswer: protectedProcedure
@@ -220,10 +342,11 @@ export const worksheetRouter = createTRPCRouter({
220
342
  points: z.number().optional(),
221
343
  })
222
344
  )
223
- .mutation(({ input }) =>
345
+ .mutation(({ ctx, input }) =>
224
346
  gradeAnswer(
225
347
  input.questionId,
226
348
  input.studentWorksheetResponseId,
349
+ ctx.user!.id,
227
350
  {
228
351
  responseId: input.responseId,
229
352
  response: input.response,
@@ -249,4 +372,36 @@ export const worksheetRouter = createTRPCRouter({
249
372
  ctx.user!.id
250
373
  )
251
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
+ }),
252
407
  });
@@ -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
  }