@studious-lms/server 1.4.1 → 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 (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
@@ -39,6 +39,43 @@ export interface DirectUploadFile {
39
39
  uploadSessionId: string;
40
40
  }
41
41
 
42
+ export async function validateDirectUploadIntent(input: {
43
+ filePath: string;
44
+ uploadSessionId: string;
45
+ userId: string;
46
+ }) {
47
+ const fileRecord = await prisma.file.findFirst({
48
+ where: {
49
+ path: input.filePath,
50
+ uploadSessionId: input.uploadSessionId,
51
+ userId: input.userId,
52
+ uploadExpiresAt: { gt: new Date() },
53
+ isOrphaned: false,
54
+ messageId: null,
55
+ },
56
+ select: {
57
+ id: true,
58
+ uploadStatus: true,
59
+ },
60
+ });
61
+
62
+ if (!fileRecord) {
63
+ throw new TRPCError({
64
+ code: 'FORBIDDEN',
65
+ message: 'Invalid or expired upload intent',
66
+ });
67
+ }
68
+
69
+ if (!['PENDING', 'UPLOADING'].includes(fileRecord.uploadStatus)) {
70
+ throw new TRPCError({
71
+ code: 'BAD_REQUEST',
72
+ message: 'Upload is no longer accepting data',
73
+ });
74
+ }
75
+
76
+ return fileRecord;
77
+ }
78
+
42
79
  // DEPRECATED: These functions are no longer used - files are uploaded directly to GCS
43
80
  // Use createDirectUploadFile() and createDirectUploadFiles() instead
44
81
 
@@ -103,7 +140,8 @@ export async function createDirectUploadFile(
103
140
  directory?: string,
104
141
  assignmentId?: string,
105
142
  submissionId?: string,
106
- announcementId?: string
143
+ announcementId?: string,
144
+ conversationId?: string
107
145
  ): Promise<DirectUploadFile> {
108
146
  try {
109
147
  // Validate file extension matches MIME type
@@ -137,7 +175,7 @@ export async function createDirectUploadFile(
137
175
 
138
176
  // Generate backend proxy upload URL (not direct GCS)
139
177
  const baseUrl = env.BACKEND_URL || 'http://localhost:3001';
140
- const uploadUrl = `${baseUrl}/api/upload/${encodeURIComponent(filePath)}`;
178
+ const uploadUrl = `${baseUrl}/api/upload/${encodeURIComponent(filePath)}?uploadSessionId=${encodeURIComponent(uploadSessionId)}`;
141
179
  const uploadExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
142
180
 
143
181
  // Create file record in database with PENDING status
@@ -173,6 +211,9 @@ export async function createDirectUploadFile(
173
211
  announcement: {
174
212
  connect: { id: announcementId }
175
213
  }
214
+ }),
215
+ ...(conversationId && {
216
+ conversationId,
176
217
  })
177
218
  },
178
219
  });
@@ -330,11 +371,20 @@ export async function createDirectUploadFiles(
330
371
  directory?: string,
331
372
  assignmentId?: string,
332
373
  submissionId?: string,
333
- announcementId?: string
374
+ announcementId?: string,
375
+ conversationId?: string
334
376
  ): Promise<DirectUploadFile[]> {
335
377
  try {
336
378
  const uploadPromises = files.map(file =>
337
- createDirectUploadFile(file, userId, directory, assignmentId, submissionId, announcementId)
379
+ createDirectUploadFile(
380
+ file,
381
+ userId,
382
+ directory,
383
+ assignmentId,
384
+ submissionId,
385
+ announcementId,
386
+ conversationId
387
+ )
338
388
  );
339
389
  return await Promise.all(uploadPromises);
340
390
  } catch (error) {
@@ -348,4 +398,4 @@ export async function createDirectUploadFiles(
348
398
  message: 'Failed to create direct upload files',
349
399
  });
350
400
  }
351
- }
401
+ }
package/src/lib/prisma.ts CHANGED
@@ -12,13 +12,32 @@ const getLogLevel = () => {
12
12
  }
13
13
  }
14
14
 
15
+ const getDatabaseUrl = () => {
16
+ try {
17
+ const url = new URL(env.DATABASE_URL);
18
+ // Reduce per-instance DB pressure in hosted/serverless environments.
19
+ if (env.NODE_ENV === 'production') {
20
+ if (!url.searchParams.has('connection_limit')) {
21
+ url.searchParams.set('connection_limit', '1');
22
+ }
23
+ if (!url.searchParams.has('pool_timeout')) {
24
+ url.searchParams.set('pool_timeout', '20');
25
+ }
26
+ }
27
+ return url.toString();
28
+ } catch {
29
+ return env.DATABASE_URL;
30
+ }
31
+ };
32
+
15
33
  const prismaClientSingleton = () => {
16
- // return new PrismaClient({
17
- // log: env.NODE_ENV === 'development'
18
- // ? ['query', 'error', 'warn']
19
- // : ['error'],
20
- // });
21
- return new PrismaClient();
34
+ return new PrismaClient({
35
+ datasources: {
36
+ db: {
37
+ url: getDatabaseUrl(),
38
+ },
39
+ },
40
+ });
22
41
  };
23
42
 
24
43
  // Prevent multiple instances of Prisma Client in development
@@ -8,11 +8,28 @@ import {
8
8
  findClassWithTeacher,
9
9
  } from "../models/auth.js";
10
10
 
11
+ function getRequestToken(headers: MiddlewareContext["ctx"]["req"]["headers"]) {
12
+ const userHeader = headers["x-user"];
13
+ if (typeof userHeader === "string") {
14
+ return userHeader;
15
+ }
16
+ if (Array.isArray(userHeader) && userHeader.length > 0) {
17
+ return userHeader[0];
18
+ }
19
+
20
+ const authorizationHeader = headers.authorization;
21
+ if (typeof authorizationHeader === "string" && authorizationHeader.startsWith("Bearer ")) {
22
+ return authorizationHeader.slice(7);
23
+ }
24
+
25
+ return null;
26
+ }
27
+
11
28
  export const createAuthMiddleware = (t: any) => {
12
29
  const isAuthed = t.middleware(async ({ next, ctx }: MiddlewareContext) => {
13
- const userHeader = ctx.req.headers["x-user"];
30
+ const token = getRequestToken(ctx.req.headers);
14
31
 
15
- if (!userHeader) {
32
+ if (!token) {
16
33
  throw new TRPCError({
17
34
  code: "UNAUTHORIZED",
18
35
  message: "Not authenticated - no token found",
@@ -20,8 +37,6 @@ export const createAuthMiddleware = (t: any) => {
20
37
  }
21
38
 
22
39
  try {
23
- const token =
24
- typeof userHeader === "string" ? userHeader : userHeader[0];
25
40
  const user = await findUserBySessionToken(token);
26
41
 
27
42
  if (!user) {
@@ -19,7 +19,7 @@ const rateLimitHandler = (req: Request, res: Response) => {
19
19
 
20
20
  // General API rate limiter - applies to all routes
21
21
  export const generalLimiter = rateLimit({
22
- windowMs: 10 * 60, // 10 minutes
22
+ windowMs: 10 * 60 * 1000, // 10 minutes
23
23
  max: 100, // Limit each IP to 100 requests per windowMs
24
24
  message: 'Too many requests from this IP, please try again later.',
25
25
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
@@ -2,9 +2,25 @@
2
2
  * Auth model – user lookup, session CRUD, Redis session cache.
3
3
  * findUserBySessionToken checks Redis first, then DB.
4
4
  */
5
+ import { SessionType } from "@prisma/client";
5
6
  import { getRedis } from "../lib/redis.js";
6
7
  import { prisma } from "../lib/prisma.js";
7
8
 
9
+ const AUTH_SESSION_CACHE_PREFIX = "auth-session:";
10
+
11
+ function getAuthSessionCacheKey(token: string) {
12
+ return `${AUTH_SESSION_CACHE_PREFIX}${token}`;
13
+ }
14
+
15
+ function getSessionTtlSeconds(expiresAt?: Date | null) {
16
+ if (!expiresAt) {
17
+ return null;
18
+ }
19
+
20
+ const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
21
+ return ttlSeconds > 0 ? ttlSeconds : null;
22
+ }
23
+
8
24
  /** @returns User by username (includes password for login). */
9
25
  export function findUserByUsername(username: string) {
10
26
  return prisma.user.findFirst({
@@ -102,8 +118,10 @@ export function createUser(data: {
102
118
  /** Create a session. */
103
119
  export function createSession(data: {
104
120
  id: string;
105
- userId: string;
121
+ type: SessionType;
106
122
  expiresAt: Date;
123
+ userId?: string;
124
+ classId?: string;
107
125
  }) {
108
126
  return prisma.session.create({
109
127
  data,
@@ -116,14 +134,14 @@ export async function deleteSessionsByUserId(userId: string) {
116
134
  where: { userId },
117
135
  });
118
136
  for (const session of sessions) {
119
- await getRedis()?.del(`session:${session.id}`);
137
+ await getRedis()?.del(getAuthSessionCacheKey(session.id));
120
138
  }
121
139
  return prisma.session.deleteMany({
122
140
  where: { userId },
123
141
  });
124
142
  }
125
143
 
126
- /** Delete short-lived sessions (no classId) expiring before date. */
144
+ /** Delete password reset sessions expiring before date. */
127
145
  export function deleteShortLivedSessionsByUserId(
128
146
  userId: string,
129
147
  expiresBefore: Date
@@ -131,7 +149,7 @@ export function deleteShortLivedSessionsByUserId(
131
149
  return prisma.session.deleteMany({
132
150
  where: {
133
151
  userId,
134
- classId: null,
152
+ type: SessionType.PASSWORD_RESET,
135
153
  expiresAt: { lte: expiresBefore },
136
154
  },
137
155
  });
@@ -144,17 +162,17 @@ export function deleteUserById(id: string) {
144
162
  });
145
163
  }
146
164
 
147
- /** @returns Session by ID. */
148
- export function findSessionById(id: string) {
149
- return prisma.session.findUnique({
150
- where: { id },
165
+ /** @returns Session by ID and type. */
166
+ export function findSessionByIdAndType(id: string, type: SessionType) {
167
+ return prisma.session.findFirst({
168
+ where: { id, type },
151
169
  });
152
170
  }
153
171
 
154
- /** @returns Session with user. */
155
- export function findSessionByIdWithUser(id: string) {
156
- return prisma.session.findUnique({
157
- where: { id },
172
+ /** @returns Session with user by ID and type. */
173
+ export function findSessionByIdWithUserAndType(id: string, type: SessionType) {
174
+ return prisma.session.findFirst({
175
+ where: { id, type },
158
176
  include: {
159
177
  user: { select: { id: true } },
160
178
  },
@@ -178,39 +196,51 @@ export function updateUserPassword(userId: string, password: string) {
178
196
  }
179
197
 
180
198
  /** Delete a session. */
181
- export function deleteSessionById(id: string) {
199
+ export async function deleteSessionById(id: string) {
200
+ await getRedis()?.del(getAuthSessionCacheKey(id));
182
201
  return prisma.session.delete({
183
202
  where: { id },
184
203
  });
185
204
  }
186
205
 
187
- /** @returns User from session token. Checks Redis first, then DB. Caches in Redis. */
206
+ /** @returns User from valid auth session token. Checks Redis first, then DB. Caches in Redis. */
188
207
  export async function findUserBySessionToken(token: string) {
189
- const session = await getRedis()?.get(`session:${token}`);
208
+ const cacheKey = getAuthSessionCacheKey(token);
209
+ const cachedUser = await getRedis()?.get(cacheKey);
190
210
 
191
- if (session)
192
- return JSON.parse(session);
211
+ if (cachedUser) {
212
+ return JSON.parse(cachedUser);
213
+ }
193
214
 
194
- const user = await prisma.user.findFirst({
215
+ const session = await prisma.session.findFirst({
195
216
  where: {
196
- sessions: { some: { id: token } },
217
+ id: token,
218
+ type: SessionType.AUTH,
219
+ userId: { not: null },
220
+ expiresAt: { gt: new Date() },
197
221
  },
198
222
  select: {
199
- id: true,
200
- username: true,
223
+ expiresAt: true,
224
+ user: {
225
+ select: {
226
+ id: true,
227
+ username: true,
228
+ },
229
+ },
201
230
  },
202
231
  });
203
232
 
204
- if (user) {
205
- const r = getRedis();
206
- if (r) {
207
- const ttlSeconds = 30 * 24 * 60 * 60; // 30 days, match session expiry
208
- await r.setex(`session:${token}`, ttlSeconds, JSON.stringify(user));
209
- }
210
- return user;
233
+ if (!session?.user) {
234
+ return null;
235
+ }
236
+
237
+ const ttlSeconds = getSessionTtlSeconds(session.expiresAt);
238
+ const r = getRedis();
239
+ if (r && ttlSeconds) {
240
+ await r.setex(cacheKey, ttlSeconds, JSON.stringify(session.user));
211
241
  }
212
242
 
213
- return null;
243
+ return session.user;
214
244
  }
215
245
 
216
246
  /** @returns Class IDs where user is a teacher. */
@@ -4,7 +4,7 @@
4
4
  * grading boundaries, syllabus, lab drafts, invite codes.
5
5
  */
6
6
 
7
- import { Assignment, Class, Folder, GradingBoundary, MarkScheme, Section, Worksheet } from "@prisma/client";
7
+ import { Assignment, Class, Folder, GradingBoundary, MarkScheme, Section, SessionType, Worksheet } from "@prisma/client";
8
8
  import type { PrismaClient, WorksheetQuestion } from "@prisma/client";
9
9
 
10
10
  import { prisma } from "../lib/prisma.js";
@@ -214,8 +214,18 @@ export function findClassWithAssignments(
214
214
  ? { studentId: options.userId }
215
215
  : undefined;
216
216
 
217
- return prisma.class.findUnique({
218
- where: { id: classId },
217
+ return prisma.class.findFirst({
218
+ where: {
219
+ id: classId,
220
+ ...(options.userId
221
+ ? {
222
+ OR: [
223
+ { students: { some: { id: options.userId } } },
224
+ { teachers: { some: { id: options.userId } } },
225
+ ],
226
+ }
227
+ : {}),
228
+ },
219
229
  include: {
220
230
  teachers: classIncludeForGet.teachers,
221
231
  students: classIncludeForGet.students,
@@ -261,6 +271,7 @@ export function findSessionByClassCode(classCode: string) {
261
271
  return prisma.session.findFirst({
262
272
  where: {
263
273
  id: { equals: classCode, mode: "insensitive" },
274
+ type: SessionType.CLASS_INVITE,
264
275
  },
265
276
  });
266
277
  }
@@ -700,4 +711,4 @@ export async function createClassByImport(classId: string, userId: string, year:
700
711
  }, { timeout: 30000 });
701
712
 
702
713
  return newClassId;
703
- }
714
+ }
@@ -18,9 +18,9 @@ export function findNotificationsByReceiverId(receiverId: string) {
18
18
  }
19
19
 
20
20
  /** @returns Notification by ID. */
21
- export function findNotificationById(id: string) {
22
- return prisma.notification.findUnique({
23
- where: { id },
21
+ export function findNotificationByIdForReceiver(id: string, receiverId: string) {
22
+ return prisma.notification.findFirst({
23
+ where: { id, receiverId },
24
24
  include: notificationInclude,
25
25
  });
26
26
  }
@@ -45,10 +45,16 @@ export function createManyNotifications(
45
45
  });
46
46
  }
47
47
 
48
- /** Mark a notification as read. */
49
- export function markNotificationAsRead(id: string) {
50
- return prisma.notification.update({
51
- where: { id },
48
+ /** Mark a receiver's notification as read. */
49
+ export async function markNotificationAsReadForReceiver(id: string, receiverId: string) {
50
+ const result = await prisma.notification.updateMany({
51
+ where: { id, receiverId },
52
52
  data: { read: true },
53
53
  });
54
+
55
+ if (result.count === 0) {
56
+ return null;
57
+ }
58
+
59
+ return findNotificationByIdForReceiver(id, receiverId);
54
60
  }
@@ -4,6 +4,21 @@
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
6
6
 
7
+ const worksheetClassMemberWhere = (userId: string) => ({
8
+ class: {
9
+ OR: [
10
+ { teachers: { some: { id: userId } } },
11
+ { students: { some: { id: userId } } },
12
+ ],
13
+ },
14
+ });
15
+
16
+ const worksheetTeacherWhere = (userId: string) => ({
17
+ class: {
18
+ teachers: { some: { id: userId } },
19
+ },
20
+ });
21
+
7
22
  /** @returns Worksheet with questions and class. */
8
23
  export function findWorksheetById(id: string) {
9
24
  return prisma.worksheet.findUnique({
@@ -22,6 +37,34 @@ export function findWorksheetByIdMinimal(id: string) {
22
37
  });
23
38
  }
24
39
 
40
+ /** @returns Worksheet by ID for an authorized class member. */
41
+ export function findWorksheetByIdForClassMember(id: string, userId: string) {
42
+ return prisma.worksheet.findFirst({
43
+ where: {
44
+ id,
45
+ ...worksheetClassMemberWhere(userId),
46
+ },
47
+ include: {
48
+ questions: { orderBy: { createdAt: "asc" as const } },
49
+ class: true,
50
+ },
51
+ });
52
+ }
53
+
54
+ /** @returns Worksheet by ID for an authorized teacher. */
55
+ export function findWorksheetByIdForTeacher(id: string, userId: string) {
56
+ return prisma.worksheet.findFirst({
57
+ where: {
58
+ id,
59
+ ...worksheetTeacherWhere(userId),
60
+ },
61
+ include: {
62
+ questions: { orderBy: { createdAt: "asc" as const } },
63
+ class: true,
64
+ },
65
+ });
66
+ }
67
+
25
68
  /** @returns Worksheets for a class. */
26
69
  export function findWorksheetsByClassId(classId: string) {
27
70
  return prisma.worksheet.findMany({
@@ -33,6 +76,26 @@ export function findWorksheetsByClassId(classId: string) {
33
76
  });
34
77
  }
35
78
 
79
+ /** @returns Worksheet question by ID for an authorized teacher. */
80
+ export function findWorksheetQuestionByIdForTeacher(id: string, userId: string) {
81
+ return prisma.worksheetQuestion.findFirst({
82
+ where: {
83
+ id,
84
+ worksheet: worksheetTeacherWhere(userId),
85
+ },
86
+ });
87
+ }
88
+
89
+ /** @returns Worksheet question scoped to a worksheet. */
90
+ export function findWorksheetQuestionByIdInWorksheet(
91
+ id: string,
92
+ worksheetId: string
93
+ ) {
94
+ return prisma.worksheetQuestion.findFirst({
95
+ where: { id, worksheetId },
96
+ });
97
+ }
98
+
36
99
  /** Create a worksheet. */
37
100
  export function createWorksheet(data: { classId: string; name: string }) {
38
101
  return prisma.worksheet.create({
@@ -118,6 +181,45 @@ export function findSubmissionById(id: string) {
118
181
  });
119
182
  }
120
183
 
184
+ /** @returns Submission if requester is the owning student or a class teacher for the worksheet. */
185
+ export function findSubmissionByIdForWorksheetAccess(
186
+ id: string,
187
+ worksheetId: string,
188
+ worksheetClassId: string,
189
+ userId: string
190
+ ) {
191
+ return prisma.submission.findFirst({
192
+ where: {
193
+ id,
194
+ assignment: {
195
+ classId: worksheetClassId,
196
+ worksheets: { some: { id: worksheetId } },
197
+ },
198
+ OR: [
199
+ {
200
+ studentId: userId,
201
+ assignment: {
202
+ class: {
203
+ students: { some: { id: userId } },
204
+ },
205
+ },
206
+ },
207
+ {
208
+ assignment: {
209
+ class: {
210
+ teachers: { some: { id: userId } },
211
+ },
212
+ },
213
+ },
214
+ ],
215
+ },
216
+ select: {
217
+ id: true,
218
+ studentId: true,
219
+ },
220
+ });
221
+ }
222
+
121
223
  const worksheetResponseInclude = {
122
224
  responses: {
123
225
  include: {
@@ -182,6 +284,29 @@ export function findWorksheetResponseById(id: string) {
182
284
  });
183
285
  }
184
286
 
287
+ /** @returns Worksheet response if requester is the owning student. */
288
+ export function findWorksheetResponseByIdForStudent(id: string, userId: string) {
289
+ return prisma.studentWorksheetResponse.findFirst({
290
+ where: {
291
+ id,
292
+ studentId: userId,
293
+ worksheet: worksheetClassMemberWhere(userId),
294
+ },
295
+ include: { responses: true, worksheet: true },
296
+ });
297
+ }
298
+
299
+ /** @returns Worksheet response if requester is a class teacher. */
300
+ export function findWorksheetResponseByIdForTeacher(id: string, userId: string) {
301
+ return prisma.studentWorksheetResponse.findFirst({
302
+ where: {
303
+ id,
304
+ worksheet: worksheetTeacherWhere(userId),
305
+ },
306
+ include: { responses: true, worksheet: true },
307
+ });
308
+ }
309
+
185
310
  /** @returns Worksheet response with question responses. */
186
311
  export function findWorksheetResponseWithResponses(
187
312
  id: string,
@@ -197,6 +322,43 @@ export function findWorksheetResponseWithResponses(
197
322
  });
198
323
  }
199
324
 
325
+ /** @returns Worksheet response with scoped responses if requester is the owning student. */
326
+ export function findWorksheetResponseWithResponsesForStudent(
327
+ id: string,
328
+ userId: string,
329
+ questionId?: string
330
+ ) {
331
+ return prisma.studentWorksheetResponse.findFirst({
332
+ where: {
333
+ id,
334
+ studentId: userId,
335
+ worksheet: worksheetClassMemberWhere(userId),
336
+ },
337
+ include: {
338
+ worksheet: true,
339
+ responses: questionId ? { where: { questionId } } : true,
340
+ },
341
+ });
342
+ }
343
+
344
+ /** @returns Worksheet response with scoped responses if requester is a class teacher. */
345
+ export function findWorksheetResponseWithResponsesForTeacher(
346
+ id: string,
347
+ userId: string,
348
+ questionId?: string
349
+ ) {
350
+ return prisma.studentWorksheetResponse.findFirst({
351
+ where: {
352
+ id,
353
+ worksheet: worksheetTeacherWhere(userId),
354
+ },
355
+ include: {
356
+ worksheet: true,
357
+ responses: questionId ? { where: { questionId } } : true,
358
+ },
359
+ });
360
+ }
361
+
200
362
  /** @returns Worksheet question by ID. */
201
363
  export function findWorksheetQuestionById(id: string) {
202
364
  return prisma.worksheetQuestion.findUnique({
@@ -204,6 +366,46 @@ export function findWorksheetQuestionById(id: string) {
204
366
  });
205
367
  }
206
368
 
369
+ /** @returns Student question progress if requester is a class teacher. */
370
+ export function findStudentQuestionProgressByIdForTeacher(
371
+ id: string,
372
+ userId: string
373
+ ) {
374
+ return prisma.studentQuestionProgress.findFirst({
375
+ where: {
376
+ id,
377
+ question: {
378
+ worksheet: worksheetTeacherWhere(userId),
379
+ },
380
+ },
381
+ include: {
382
+ question: true,
383
+ studentWorksheetResponse: true,
384
+ },
385
+ });
386
+ }
387
+
388
+ /** @returns Student question progress by id and worksheet response if requester is a class teacher. */
389
+ export function findStudentQuestionProgressByIdForTeacherAndResponse(
390
+ id: string,
391
+ studentWorksheetResponseId: string,
392
+ userId: string
393
+ ) {
394
+ return prisma.studentQuestionProgress.findFirst({
395
+ where: {
396
+ id,
397
+ studentWorksheetResponseId,
398
+ question: {
399
+ worksheet: worksheetTeacherWhere(userId),
400
+ },
401
+ },
402
+ include: {
403
+ question: true,
404
+ studentWorksheetResponse: true,
405
+ },
406
+ });
407
+ }
408
+
207
409
  /** Update student's question progress. */
208
410
  export function updateStudentQuestionProgress(
209
411
  id: string,