@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.
- package/.coderabbit.yaml +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -28
- package/dist/index.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +3 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/notification.d.ts +11 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/worksheet.d.ts +378 -7
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +124 -18
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/labChat.d.ts +6 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +31 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +6 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +9 -2
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/worksheet.d.ts +8 -6
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +18 -16
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +12 -5
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +15 -15
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +138 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +9 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +138 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +80 -28
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +202 -0
- package/src/pipelines/aiLabChat.ts +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/worksheet.ts +31 -19
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +4 -1
- package/src/services/worksheet.ts +191 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +209 -55
- package/tests/globalSetup.ts +13 -4
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/inference.test.ts +218 -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
|
|
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
|
|
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,16 +461,50 @@ 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
499
|
app.post('/api/upload/:filePath', uploadLimiter, async (req, res) => {
|
|
468
|
-
handleFileUpload(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:
|
|
507
|
+
async function handleFileUpload(req: Request, res: Response) {
|
|
476
508
|
try {
|
|
477
509
|
const filePath = decodeURIComponent(req.params.filePath);
|
|
478
510
|
|
|
@@ -488,23 +520,33 @@ 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
|
|
499
542
|
const writeStream = file.createWriteStream({
|
|
500
543
|
metadata: {
|
|
501
544
|
contentType,
|
|
502
545
|
},
|
|
503
546
|
});
|
|
504
547
|
|
|
505
|
-
// Handle stream events
|
|
506
548
|
writeStream.on('error', (error) => {
|
|
507
|
-
logger.error('Error uploading file:', {error});
|
|
549
|
+
logger.error('Error uploading file:', {error, filePath, userId: user.id});
|
|
508
550
|
if (!res.headersSent) {
|
|
509
551
|
res.status(500).json({ error: 'Error uploading file' });
|
|
510
552
|
}
|
|
@@ -518,11 +560,21 @@ function handleFileUpload(req: any, res: any) {
|
|
|
518
560
|
});
|
|
519
561
|
});
|
|
520
562
|
|
|
521
|
-
// Pipe the request body to the write stream
|
|
522
563
|
req.pipe(writeStream);
|
|
523
564
|
|
|
524
565
|
} catch (error) {
|
|
525
566
|
logger.error('Error handling file upload:', {error});
|
|
567
|
+
const code = typeof error === 'object' && error && 'code' in error ? error.code : null;
|
|
568
|
+
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
569
|
+
|
|
570
|
+
if (code === 'FORBIDDEN') {
|
|
571
|
+
return res.status(403).json({ error: message });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (code === 'BAD_REQUEST') {
|
|
575
|
+
return res.status(400).json({ error: message });
|
|
576
|
+
}
|
|
577
|
+
|
|
526
578
|
res.status(500).json({ error: 'Internal server error' });
|
|
527
579
|
}
|
|
528
580
|
}
|
package/src/lib/fileUpload.ts
CHANGED
|
@@ -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(
|
|
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/middleware/auth.ts
CHANGED
|
@@ -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
|
|
30
|
+
const token = getRequestToken(ctx.req.headers);
|
|
14
31
|
|
|
15
|
-
if (!
|
|
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) {
|
package/src/models/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
149
|
-
return prisma.session.
|
|
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
|
|
156
|
-
return prisma.session.
|
|
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
|
|
208
|
+
const cacheKey = getAuthSessionCacheKey(token);
|
|
209
|
+
const cachedUser = await getRedis()?.get(cacheKey);
|
|
190
210
|
|
|
191
|
-
if (
|
|
192
|
-
return JSON.parse(
|
|
211
|
+
if (cachedUser) {
|
|
212
|
+
return JSON.parse(cachedUser);
|
|
213
|
+
}
|
|
193
214
|
|
|
194
|
-
const
|
|
215
|
+
const session = await prisma.session.findFirst({
|
|
195
216
|
where: {
|
|
196
|
-
|
|
217
|
+
id: token,
|
|
218
|
+
type: SessionType.AUTH,
|
|
219
|
+
userId: { not: null },
|
|
220
|
+
expiresAt: { gt: new Date() },
|
|
197
221
|
},
|
|
198
222
|
select: {
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
243
|
+
return session.user;
|
|
214
244
|
}
|
|
215
245
|
|
|
216
246
|
/** @returns Class IDs where user is a teacher. */
|
package/src/models/class.ts
CHANGED
|
@@ -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.
|
|
218
|
-
where: {
|
|
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
|
|
22
|
-
return prisma.notification.
|
|
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
|
|
50
|
-
|
|
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
|
}
|