@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
package/src/index.ts CHANGED
@@ -7,9 +7,11 @@ import dotenv from 'dotenv';
7
7
  import { createExpressMiddleware } from '@trpc/server/adapters/express';
8
8
  import { appRouter } from './routers/_app.js';
9
9
  import { createTRPCContext, createCallerFactory } from './trpc.js';
10
+ import { findUserBySessionToken } from './models/auth.js';
10
11
  import { logger } from './utils/logger.js';
11
12
  import { setupSocketHandlers } from './socket/handlers.js';
12
13
  import { bucket } from './lib/googleCloudStorage.js';
14
+ import { validateDirectUploadIntent } from './lib/fileUpload.js';
13
15
  import { prisma } from './lib/prisma.js';
14
16
  import { pusher } from './lib/pusher.js';
15
17
  import { connectRedis, disconnectRedis } from './lib/redis.js';
@@ -115,6 +117,17 @@ app.use((req, res, next) => {
115
117
  // Create HTTP server
116
118
  const httpServer = createServer(app);
117
119
 
120
+ app.get('/', async (req, res) => {
121
+ res.status(200).json({
122
+ status: 'OK',
123
+ timestamp: new Date().toISOString(),
124
+ uptime: process.uptime(),
125
+ version: process.env.npm_package_version,
126
+ environment: process.env.NODE_ENV,
127
+ appUrl: process.env.NEXT_PUBLIC_APP_URL,
128
+ });
129
+ });
130
+
118
131
  app.get('/health', async (req, res) => {
119
132
 
120
133
  try {
@@ -156,10 +169,7 @@ app.post('/api/pusher/auth', async (req, res) => {
156
169
  return res.status(401).json({ error: 'Authentication required' });
157
170
  }
158
171
 
159
- const user = await prisma.user.findFirst({
160
- where: { sessions: { some: { id: token } } },
161
- select: { id: true, username: true },
162
- });
172
+ const user = await findUserBySessionToken(token);
163
173
 
164
174
  if (!user) {
165
175
  return res.status(401).json({ error: 'Invalid or expired session' });
@@ -274,19 +284,7 @@ app.get('/api/files/:fileId', async (req, res) => {
274
284
  const token = typeof userHeader === 'string' ? userHeader : userHeader[0];
275
285
 
276
286
  // Find user by session token
277
- const user = await prisma.user.findFirst({
278
- where: {
279
- sessions: {
280
- some: {
281
- id: token
282
- }
283
- }
284
- },
285
- select: {
286
- id: true,
287
- username: true,
288
- }
289
- });
287
+ const user = await findUserBySessionToken(token);
290
288
 
291
289
  if (!user) {
292
290
  return res.status(401).json({ error: 'Invalid or expired session' });
@@ -463,19 +461,53 @@ app.get('/api/files/:fileId', async (req, res) => {
463
461
  app.use('/trpc/auth.login', authLimiter);
464
462
  app.use('/trpc/auth.register', authLimiter);
465
463
 
464
+ async function getAuthenticatedRequestUser(req: Request) {
465
+ const userHeader = req.headers['x-user'];
466
+ const tokenFromUserHeader = typeof userHeader === 'string' ? userHeader : userHeader?.[0];
467
+ const authorizationHeader = req.headers.authorization;
468
+ const tokenFromAuthorization =
469
+ typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')
470
+ ? authorizationHeader.slice(7)
471
+ : null;
472
+
473
+ let token = tokenFromUserHeader ?? tokenFromAuthorization;
474
+
475
+ if (!token && req.headers.cookie) {
476
+ const cookieName = env.PUSHER_AUTH_COOKIE_NAME || 'token';
477
+ const match = req.headers.cookie.match(new RegExp(cookieName + '=([^;]+)'));
478
+ token = match?.[1]?.trim() ?? null;
479
+ }
480
+
481
+ if (!token) {
482
+ return null;
483
+ }
484
+
485
+ return findUserBySessionToken(token);
486
+ }
487
+
488
+ function getUploadSessionId(req: Request) {
489
+ const queryValue = req.query.uploadSessionId;
490
+ if (typeof queryValue === 'string' && queryValue.trim()) {
491
+ return queryValue;
492
+ }
493
+
494
+ const headerValue = req.headers['x-upload-session-id'];
495
+ return typeof headerValue === 'string' ? headerValue : headerValue?.[0];
496
+ }
497
+
466
498
  // File upload endpoint for secure file uploads (supports both POST and PUT)
467
- app.post('/api/upload/:filePath', uploadLimiter, async (req, res) => {
468
- handleFileUpload(req, res);
499
+ app.post('/api/upload/:filePath', async (req, res) => {
500
+ await handleFileUpload(req, res);
469
501
  });
470
502
 
471
503
  app.put('/api/upload/:filePath', uploadLimiter, async (req, res) => {
472
- handleFileUpload(req, res);
504
+ await handleFileUpload(req, res);
473
505
  });
474
506
 
475
- function handleFileUpload(req: any, res: any) {
507
+ async function handleFileUpload(req: Request, res: Response) {
476
508
  try {
477
509
  const filePath = decodeURIComponent(req.params.filePath);
478
-
510
+
479
511
  // Set CORS headers for upload endpoint
480
512
  const origin = req.headers.origin;
481
513
 
@@ -488,23 +520,34 @@ function handleFileUpload(req: any, res: any) {
488
520
  }
489
521
 
490
522
  res.header('Access-Control-Allow-Credentials', 'true');
523
+
524
+ const user = await getAuthenticatedRequestUser(req);
525
+ if (!user) {
526
+ return res.status(401).json({ error: 'Authentication required' });
527
+ }
528
+
529
+ const uploadSessionId = getUploadSessionId(req);
530
+ if (!uploadSessionId) {
531
+ return res.status(401).json({ error: 'Upload session is required' });
532
+ }
533
+
534
+ await validateDirectUploadIntent({
535
+ filePath,
536
+ uploadSessionId,
537
+ userId: user.id,
538
+ });
491
539
 
492
- // Get content type from headers
493
540
  const contentType = req.headers['content-type'] || 'application/octet-stream';
494
-
495
- // Create a new file in the bucket
496
541
  const file = bucket.file(filePath);
497
-
498
- // Create a write stream to Google Cloud Storage
542
+
499
543
  const writeStream = file.createWriteStream({
500
544
  metadata: {
501
545
  contentType,
502
546
  },
503
547
  });
504
548
 
505
- // Handle stream events
506
549
  writeStream.on('error', (error) => {
507
- logger.error('Error uploading file:', {error});
550
+ logger.error('Error uploading file:', {error, filePath, userId: user.id});
508
551
  if (!res.headersSent) {
509
552
  res.status(500).json({ error: 'Error uploading file' });
510
553
  }
@@ -518,11 +561,21 @@ function handleFileUpload(req: any, res: any) {
518
561
  });
519
562
  });
520
563
 
521
- // Pipe the request body to the write stream
522
564
  req.pipe(writeStream);
523
565
 
524
566
  } catch (error) {
525
567
  logger.error('Error handling file upload:', {error});
568
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : null;
569
+ const message = error instanceof Error ? error.message : 'Internal server error';
570
+
571
+ if (code === 'FORBIDDEN') {
572
+ return res.status(403).json({ error: message });
573
+ }
574
+
575
+ if (code === 'BAD_REQUEST') {
576
+ return res.status(400).json({ error: message });
577
+ }
578
+
526
579
  res.status(500).json({ error: 'Internal server error' });
527
580
  }
528
581
  }
@@ -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
+ }
@@ -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) {
@@ -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
  }