@studious-lms/server 1.4.7 → 1.5.0
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/.github/workflows/npm.yml +28 -0
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +2 -4
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +4 -5
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/models/assignment.d.ts +32 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/assignment.js +111 -2
- package/dist/models/assignment.js.map +1 -1
- package/dist/models/auth.d.ts +1 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +3 -2
- package/dist/models/auth.js.map +1 -1
- package/dist/models/comment.d.ts +0 -5
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +2 -1
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +2 -1
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +6 -3
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/school.d.ts +0 -22
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +2 -14
- package/dist/models/school.js.map +1 -1
- package/dist/models/worksheet.d.ts +174 -95
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +38 -5
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.js +6 -6
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +5 -3
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +60 -24
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +3 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +65 -24
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +232 -48
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +0 -4
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +35 -8
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +10 -3
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/auth.d.ts +1 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/class.d.ts +19 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +0 -3
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/conversation.d.ts +2 -1
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +2 -1
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +7 -2
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/worksheet.d.ts +51 -7
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +10 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/assignment.d.ts +30 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +37 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/class.d.ts +19 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +37 -19
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +0 -3
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/conversation.d.ts +2 -1
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/labChat.d.ts +2 -1
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +6 -6
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +14 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +71 -6
- package/dist/services/school.js.map +1 -1
- package/dist/services/worksheet.d.ts +46 -7
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +52 -20
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/generateUsername.d.ts +8 -0
- package/dist/utils/generateUsername.d.ts.map +1 -0
- package/dist/utils/generateUsername.js +103 -0
- package/dist/utils/generateUsername.js.map +1 -0
- package/dist/workers/connection.d.ts +3 -0
- package/dist/workers/connection.d.ts.map +1 -0
- package/dist/workers/connection.js +33 -0
- package/dist/workers/connection.js.map +1 -0
- package/dist/workers/index.d.ts +10 -0
- package/dist/workers/index.d.ts.map +1 -0
- package/dist/workers/index.js +53 -0
- package/dist/workers/index.js.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts +9 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.js +100 -0
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
- package/dist/workers/queues.d.ts +69 -0
- package/dist/workers/queues.d.ts.map +1 -0
- package/dist/workers/queues.js +119 -0
- package/dist/workers/queues.js.map +1 -0
- package/package.json +4 -1
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +1 -1
- package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
- package/prisma/schema.prisma +465 -387
- package/src/lib/config/cors.ts +0 -2
- package/src/lib/fileUpload.ts +2 -4
- package/src/models/assignment.ts +132 -0
- package/src/models/auth.ts +1 -0
- package/src/models/school.ts +0 -14
- package/src/models/worksheet.ts +56 -2
- package/src/pipelines/aiLabChat.ts +4 -4
- package/src/pipelines/gradeWorksheet.ts +63 -25
- package/src/pipelines/pdfToWorksheet.ts +68 -22
- package/src/routers/assignment.ts +19 -0
- package/src/routers/school.ts +5 -0
- package/src/routers/worksheet.ts +18 -0
- package/src/services/assignment.ts +47 -1
- package/src/services/class.ts +41 -17
- package/src/services/labChat.ts +4 -4
- package/src/services/message.ts +13 -0
- package/src/services/school.ts +89 -6
- package/src/services/worksheet.ts +62 -18
- package/src/utils/generateUsername.ts +108 -0
- package/src/workers/connection.ts +37 -0
- package/src/workers/index.ts +61 -0
- package/src/workers/processors/pdfToWorksheet.ts +119 -0
- package/src/workers/queues.ts +159 -0
- package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
- package/tests/{helpers.ts → integration/helpers.ts} +2 -2
- package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
- package/tests/{routers → integration/routers}/attendance.test.ts +1 -1
- package/tests/{routers → integration/routers}/auth.test.ts +3 -3
- package/tests/{routers → integration/routers}/class.test.ts +3 -17
- package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
- package/tests/{routers → integration/routers}/event.test.ts +1 -1
- package/tests/{routers → integration/routers}/message.test.ts +1 -1
- package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
- package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
- package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
- package/tests/integration/routers/worksheet.test.ts +448 -0
- package/tests/{setup.ts → integration/setup.ts} +4 -4
- package/tests/{lib → unit/lib}/cors.test.ts +1 -1
- package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
- package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
- package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
- package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
- package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
- package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
- package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
- package/tests/{utils → unit/utils}/inference.test.ts +6 -6
- package/tests/{utils → unit/utils}/logger.test.ts +1 -1
- package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
- package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
- package/vitest.config.ts +11 -3
- package/vitest.unit.config.ts +10 -10
- package/dist/lib/notificationHandler.d.ts +0 -25
- package/dist/lib/notificationHandler.d.ts.map +0 -1
- package/dist/lib/notificationHandler.js +0 -32
- package/dist/lib/notificationHandler.js.map +0 -1
- package/dist/server/pipelines/aiLabChat.d.ts +0 -21
- package/dist/server/pipelines/aiLabChat.d.ts.map +0 -1
- package/dist/server/pipelines/aiLabChat.js +0 -456
- package/dist/server/pipelines/aiLabChat.js.map +0 -1
- package/dist/server/pipelines/aiNewtonChat.d.ts +0 -30
- package/dist/server/pipelines/aiNewtonChat.d.ts.map +0 -1
- package/dist/server/pipelines/aiNewtonChat.js +0 -285
- package/dist/server/pipelines/aiNewtonChat.js.map +0 -1
- package/dist/server/pipelines/gradeWorksheet.d.ts +0 -30
- package/dist/server/pipelines/gradeWorksheet.d.ts.map +0 -1
- package/dist/server/pipelines/gradeWorksheet.js +0 -248
- package/dist/server/pipelines/gradeWorksheet.js.map +0 -1
- package/tests/routers/worksheet.test.ts +0 -194
- package/tests/startup.test.ts +0 -5
- package/tests/worksheet.test.ts +0 -181
- /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
- /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
- /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
- /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
- /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
- /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
- /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
- /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
- /package/tests/{routers → integration/routers}/search.test.ts +0 -0
- /package/tests/{routers → integration/routers}/section.test.ts +0 -0
- /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
|
@@ -6,6 +6,7 @@ import { prisma } from "../lib/prisma.js";
|
|
|
6
6
|
import {
|
|
7
7
|
findAssignmentById,
|
|
8
8
|
findAssignmentWithDetails,
|
|
9
|
+
findAssignmentWithClassForTeacher,
|
|
9
10
|
findAssignmentForDelete,
|
|
10
11
|
findAssignmentsDueToday,
|
|
11
12
|
findSubmissionByAssignmentAndStudent,
|
|
@@ -15,10 +16,10 @@ import {
|
|
|
15
16
|
findSubmissionsByAssignmentForTeacher,
|
|
16
17
|
createSubmission,
|
|
17
18
|
findSectionsByClassId,
|
|
18
|
-
findAssignmentWithClassForTeacher,
|
|
19
19
|
findAvailableEventsForAssignment,
|
|
20
20
|
attachAssignmentToEvent,
|
|
21
21
|
detachAssignmentFromEvent,
|
|
22
|
+
duplicateAssignment,
|
|
22
23
|
} from "../models/assignment.js";
|
|
23
24
|
import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
|
|
24
25
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
@@ -320,6 +321,51 @@ export async function createAssignmentRecord(
|
|
|
320
321
|
return assignment;
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Duplicate an assignment into another class. Caller must teach both the source assignment’s
|
|
326
|
+
* class and the target class.
|
|
327
|
+
*/
|
|
328
|
+
export async function duplicateAssignmentRecord(
|
|
329
|
+
assignmentId: string,
|
|
330
|
+
classId: string,
|
|
331
|
+
title: string,
|
|
332
|
+
userId: string
|
|
333
|
+
) {
|
|
334
|
+
const source = await findAssignmentWithClassForTeacher(assignmentId, userId);
|
|
335
|
+
if (!source) {
|
|
336
|
+
throw new TRPCError({
|
|
337
|
+
code: "NOT_FOUND",
|
|
338
|
+
message:
|
|
339
|
+
"Assignment not found or you are not a teacher of the class it belongs to",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const targetOk = await prisma.class.findFirst({
|
|
344
|
+
where: {
|
|
345
|
+
id: classId,
|
|
346
|
+
teachers: { some: { id: userId } },
|
|
347
|
+
},
|
|
348
|
+
select: { id: true },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (!targetOk) {
|
|
352
|
+
throw new TRPCError({
|
|
353
|
+
code: "FORBIDDEN",
|
|
354
|
+
message: "You are not a teacher of the target class",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const created = await duplicateAssignment(assignmentId, classId, title, userId);
|
|
359
|
+
if (!created) {
|
|
360
|
+
throw new TRPCError({
|
|
361
|
+
code: "NOT_FOUND",
|
|
362
|
+
message: "Assignment not found or could not be duplicated",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return created;
|
|
367
|
+
}
|
|
368
|
+
|
|
323
369
|
export async function updateAssignmentRecord(
|
|
324
370
|
userId: string,
|
|
325
371
|
input: {
|
package/src/services/class.ts
CHANGED
|
@@ -25,9 +25,35 @@ import {
|
|
|
25
25
|
findFullExportableClass,
|
|
26
26
|
createClassByImport,
|
|
27
27
|
} from "../models/class.js";
|
|
28
|
-
import { listUserSchoolMemberships } from "../models/school.js";
|
|
29
28
|
import { Class, Folder } from "@prisma/client";
|
|
30
29
|
|
|
30
|
+
async function requireActiveSchoolMembers(schoolId: string, userIds: string[]) {
|
|
31
|
+
const uniqueUserIds = [...new Set(userIds)];
|
|
32
|
+
if (!uniqueUserIds.length) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const memberships = await prisma.schoolMembership.findMany({
|
|
37
|
+
where: {
|
|
38
|
+
schoolId,
|
|
39
|
+
status: "ACTIVE",
|
|
40
|
+
userId: { in: uniqueUserIds },
|
|
41
|
+
},
|
|
42
|
+
select: { userId: true },
|
|
43
|
+
});
|
|
44
|
+
const activeMemberIds = new Set(
|
|
45
|
+
memberships.map((membership) => membership.userId)
|
|
46
|
+
);
|
|
47
|
+
const hasNonMember = uniqueUserIds.some((id) => !activeMemberIds.has(id));
|
|
48
|
+
|
|
49
|
+
if (hasNonMember) {
|
|
50
|
+
throw new TRPCError({
|
|
51
|
+
code: "FORBIDDEN",
|
|
52
|
+
message: "All class members must belong to this school",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
/** Get all classes where user is teacher or student, with assignments and members. */
|
|
32
58
|
export async function getAllClasses(userId: string) {
|
|
33
59
|
const [teacherClasses, studentClasses] = await Promise.all([
|
|
@@ -128,8 +154,10 @@ export async function createClass(
|
|
|
128
154
|
const { students, teachers, name, section, subject, color, schoolId } = input;
|
|
129
155
|
|
|
130
156
|
if (schoolId) {
|
|
131
|
-
const
|
|
132
|
-
|
|
157
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
158
|
+
where: { userId, schoolId, status: "ACTIVE" },
|
|
159
|
+
select: { role: true },
|
|
160
|
+
});
|
|
133
161
|
|
|
134
162
|
if (!membership) {
|
|
135
163
|
throw new TRPCError({
|
|
@@ -146,19 +174,11 @@ export async function createClass(
|
|
|
146
174
|
}
|
|
147
175
|
}
|
|
148
176
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
subject,
|
|
155
|
-
color,
|
|
156
|
-
schoolId,
|
|
157
|
-
teachers: { connect: teachers.map((id) => ({ id })) },
|
|
158
|
-
students: { connect: students.map((id) => ({ id })) },
|
|
159
|
-
},
|
|
160
|
-
include: { teachers: true, students: true },
|
|
161
|
-
});
|
|
177
|
+
const teacherIds = teachers?.length ? teachers : [userId];
|
|
178
|
+
const studentIds = students?.length ? students : [];
|
|
179
|
+
|
|
180
|
+
if (schoolId) {
|
|
181
|
+
await requireActiveSchoolMembers(schoolId, [...teacherIds, ...studentIds]);
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
return prisma.class.create({
|
|
@@ -168,8 +188,12 @@ export async function createClass(
|
|
|
168
188
|
subject,
|
|
169
189
|
color,
|
|
170
190
|
schoolId,
|
|
171
|
-
teachers: { connect: { id
|
|
191
|
+
teachers: { connect: teacherIds.map((id) => ({ id })) },
|
|
192
|
+
...(studentIds.length
|
|
193
|
+
? { students: { connect: studentIds.map((id) => ({ id })) } }
|
|
194
|
+
: {}),
|
|
172
195
|
},
|
|
196
|
+
include: { teachers: true, students: true },
|
|
173
197
|
});
|
|
174
198
|
}
|
|
175
199
|
|
package/src/services/labChat.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* trigger AI responses, and broadcast via Pusher.
|
|
4
4
|
*/
|
|
5
5
|
import { TRPCError } from "@trpc/server";
|
|
6
|
-
import {
|
|
6
|
+
import { JobStatus } from "@prisma/client";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { chatChannel, teacherChannel, pusher } from "../lib/pusher.js";
|
|
9
9
|
import {
|
|
@@ -175,7 +175,7 @@ export async function getLabChat(userId: string, labChatId: string) {
|
|
|
175
175
|
const pendingMessage = await prisma.message.findFirst({
|
|
176
176
|
where: {
|
|
177
177
|
conversationId: labChat.conversationId,
|
|
178
|
-
status:
|
|
178
|
+
status: JobStatus.PENDING,
|
|
179
179
|
senderId: { not: "AI_ASSISTANT" },
|
|
180
180
|
},
|
|
181
181
|
orderBy: { createdAt: "desc" },
|
|
@@ -252,7 +252,7 @@ export async function postToLabChat(
|
|
|
252
252
|
content: normalizedContent,
|
|
253
253
|
senderId: userId,
|
|
254
254
|
conversationId: labChat.conversationId,
|
|
255
|
-
...(isAIUser(userId) ? {} : { status:
|
|
255
|
+
...(isAIUser(userId) ? {} : { status: JobStatus.PENDING }),
|
|
256
256
|
},
|
|
257
257
|
});
|
|
258
258
|
|
|
@@ -452,7 +452,7 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
|
452
452
|
}),
|
|
453
453
|
prisma.message.update({
|
|
454
454
|
where: { id: teacherMessage.id },
|
|
455
|
-
data: { status:
|
|
455
|
+
data: { status: JobStatus.PENDING },
|
|
456
456
|
}),
|
|
457
457
|
]);
|
|
458
458
|
|
package/src/services/message.ts
CHANGED
|
@@ -110,6 +110,19 @@ export async function confirmMessageUpload(
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
if (input.uploadSuccess) {
|
|
114
|
+
await prisma.$transaction(async (tx) => {
|
|
115
|
+
await tx.file.update({
|
|
116
|
+
where: { id: input.fileId },
|
|
117
|
+
data: { uploadStatus: "FAILED", uploadError: input.errorMessage },
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
throw new TRPCError({
|
|
121
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
122
|
+
message: "Failed to confirm upload",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
const file = await prisma.file.findFirst({
|
|
114
127
|
where: {
|
|
115
128
|
id: input.fileId,
|
package/src/services/school.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* School service - school admin workflow, memberships, and invites.
|
|
3
3
|
*/
|
|
4
|
-
import { SchoolMembershipRole } from "@prisma/client";
|
|
4
|
+
import { SchoolMembershipRole, SessionType } from "@prisma/client";
|
|
5
5
|
import { TRPCError } from "@trpc/server";
|
|
6
|
+
import { hash } from "bcryptjs";
|
|
6
7
|
import { v4 as uuidv4 } from "uuid";
|
|
7
8
|
import { env } from "../lib/config/env.js";
|
|
8
9
|
import { prisma } from "../lib/prisma.js";
|
|
9
10
|
import { sendMail } from "../utils/email.js";
|
|
11
|
+
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
10
12
|
import { logger } from "../utils/logger.js";
|
|
13
|
+
import {
|
|
14
|
+
createSession,
|
|
15
|
+
createUser,
|
|
16
|
+
findUserByEmail,
|
|
17
|
+
findUserById,
|
|
18
|
+
findUserByUsername,
|
|
19
|
+
} from "../models/auth.js";
|
|
20
|
+
import { upsertUserProfile } from "../models/user.js";
|
|
11
21
|
import {
|
|
12
22
|
acceptSchoolInvite,
|
|
13
23
|
countActiveAdmins,
|
|
@@ -16,7 +26,6 @@ import {
|
|
|
16
26
|
findActiveSchoolAdminMembership,
|
|
17
27
|
findPendingInviteByToken,
|
|
18
28
|
findSchoolMembership,
|
|
19
|
-
findUserByIdForSchool,
|
|
20
29
|
getSchoolById,
|
|
21
30
|
getSchoolOverview,
|
|
22
31
|
listSchoolClasses,
|
|
@@ -225,9 +234,61 @@ export async function listInvites(userId: string, schoolId: string) {
|
|
|
225
234
|
|
|
226
235
|
export async function createInvite(
|
|
227
236
|
userId: string,
|
|
228
|
-
input: {
|
|
237
|
+
input: {
|
|
238
|
+
schoolId: string;
|
|
239
|
+
email: string;
|
|
240
|
+
role: SchoolMembershipRole;
|
|
241
|
+
username?: string;
|
|
242
|
+
displayName?: string;
|
|
243
|
+
}
|
|
229
244
|
) {
|
|
230
245
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
246
|
+
|
|
247
|
+
const existingUser = await findUserByEmail(input.email);
|
|
248
|
+
|
|
249
|
+
let createdUser: { id: string; username: string } | null = null;
|
|
250
|
+
let passwordResetToken: string | null = null;
|
|
251
|
+
|
|
252
|
+
if (!existingUser) {
|
|
253
|
+
let username: string;
|
|
254
|
+
if (input.username) {
|
|
255
|
+
const taken = await findUserByUsername(input.username);
|
|
256
|
+
if (taken) {
|
|
257
|
+
throw new TRPCError({
|
|
258
|
+
code: "CONFLICT",
|
|
259
|
+
message: "Username already taken",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
username = input.username;
|
|
263
|
+
} else {
|
|
264
|
+
username = await generateUniqueUsername(async (candidate) => {
|
|
265
|
+
const existing = await findUserByUsername(candidate);
|
|
266
|
+
return Boolean(existing);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const placeholderPassword = await hash(uuidv4(), 10);
|
|
271
|
+
const user = await createUser({
|
|
272
|
+
username,
|
|
273
|
+
email: input.email,
|
|
274
|
+
password: placeholderPassword,
|
|
275
|
+
verified: true,
|
|
276
|
+
});
|
|
277
|
+
createdUser = { id: user.id, username: user.username };
|
|
278
|
+
|
|
279
|
+
if (input.displayName) {
|
|
280
|
+
await upsertUserProfile(user.id, { displayName: input.displayName });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const resetSession = await createSession({
|
|
284
|
+
id: uuidv4(),
|
|
285
|
+
type: SessionType.PASSWORD_RESET,
|
|
286
|
+
userId: user.id,
|
|
287
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
288
|
+
});
|
|
289
|
+
passwordResetToken = resetSession.id;
|
|
290
|
+
}
|
|
291
|
+
|
|
231
292
|
const token = uuidv4();
|
|
232
293
|
const invite = await createSchoolInvite({
|
|
233
294
|
schoolId: input.schoolId,
|
|
@@ -238,12 +299,34 @@ export async function createInvite(
|
|
|
238
299
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
239
300
|
});
|
|
240
301
|
|
|
302
|
+
if (createdUser) {
|
|
303
|
+
await acceptSchoolInvite(invite.id, createdUser.id);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const inviteUrl = `${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`;
|
|
307
|
+
const passwordResetUrl = passwordResetToken
|
|
308
|
+
? `${env.NEXT_PUBLIC_APP_URL}/reset-password/${passwordResetToken}`
|
|
309
|
+
: null;
|
|
310
|
+
|
|
311
|
+
const emailLines = [
|
|
312
|
+
`You've been invited to ${invite.school.name} on Studious.`,
|
|
313
|
+
"",
|
|
314
|
+
];
|
|
315
|
+
if (createdUser && passwordResetUrl) {
|
|
316
|
+
emailLines.push(
|
|
317
|
+
`We created an account for you with the username "${createdUser.username}" and added you to ${invite.school.name}.`,
|
|
318
|
+
`Set your password to get started: ${passwordResetUrl}`
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
emailLines.push(`Click the link to confirm: ${inviteUrl}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
241
324
|
try {
|
|
242
325
|
await sendMail({
|
|
243
326
|
from: "hello@studious.sh",
|
|
244
327
|
to: input.email,
|
|
245
328
|
subject: `Join ${invite.school.name} on Studious`,
|
|
246
|
-
text:
|
|
329
|
+
text: emailLines.join("\n"),
|
|
247
330
|
});
|
|
248
331
|
} catch (err) {
|
|
249
332
|
logger.error("Failed to send school invite email", {
|
|
@@ -253,7 +336,7 @@ export async function createInvite(
|
|
|
253
336
|
});
|
|
254
337
|
}
|
|
255
338
|
|
|
256
|
-
return { invite };
|
|
339
|
+
return { invite, createdUser };
|
|
257
340
|
}
|
|
258
341
|
|
|
259
342
|
export async function revokeInvite(
|
|
@@ -292,7 +375,7 @@ export async function acceptInvite(userId: string, token: string) {
|
|
|
292
375
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
|
|
293
376
|
}
|
|
294
377
|
|
|
295
|
-
const user = await
|
|
378
|
+
const user = await findUserById(userId);
|
|
296
379
|
if (!user) {
|
|
297
380
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
298
381
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Integrates with gradeWorksheet pipeline for auto-grading.
|
|
4
4
|
*/
|
|
5
5
|
import { TRPCError } from "@trpc/server";
|
|
6
|
-
import {
|
|
6
|
+
import { JobStatus, type WorksheetQuestionType } from "@prisma/client";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { getFileUrl } from "../lib/fileUpload.js";
|
|
9
9
|
import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
findWorksheetByIdForTeacher,
|
|
15
15
|
findWorksheetsByClassId,
|
|
16
16
|
createWorksheet,
|
|
17
|
+
duplicateWorksheet,
|
|
17
18
|
updateWorksheet,
|
|
18
19
|
deleteWorksheet,
|
|
19
20
|
findQuestionsByWorksheetId,
|
|
@@ -123,6 +124,51 @@ export async function createWorksheetRecord(classId: string, name: string) {
|
|
|
123
124
|
return createWorksheet({ classId, name });
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Duplicate a worksheet into another class. Caller must teach both the source worksheet's class
|
|
129
|
+
* and the target class.
|
|
130
|
+
*/
|
|
131
|
+
export async function duplicateWorksheetRecord(
|
|
132
|
+
worksheetId: string,
|
|
133
|
+
classId: string,
|
|
134
|
+
name: string,
|
|
135
|
+
userId: string
|
|
136
|
+
) {
|
|
137
|
+
const source = await findWorksheetByIdForTeacher(worksheetId, userId);
|
|
138
|
+
if (!source) {
|
|
139
|
+
throw new TRPCError({
|
|
140
|
+
code: "NOT_FOUND",
|
|
141
|
+
message:
|
|
142
|
+
"Worksheet not found or you are not a teacher of the class it belongs to",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const targetOk = await prisma.class.findFirst({
|
|
147
|
+
where: {
|
|
148
|
+
id: classId,
|
|
149
|
+
teachers: { some: { id: userId } },
|
|
150
|
+
},
|
|
151
|
+
select: { id: true },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!targetOk) {
|
|
155
|
+
throw new TRPCError({
|
|
156
|
+
code: "FORBIDDEN",
|
|
157
|
+
message: "You are not a teacher of the target class",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const created = await duplicateWorksheet(worksheetId, classId, name);
|
|
162
|
+
if (!created) {
|
|
163
|
+
throw new TRPCError({
|
|
164
|
+
code: "NOT_FOUND",
|
|
165
|
+
message: "Worksheet not found",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return created;
|
|
170
|
+
}
|
|
171
|
+
|
|
126
172
|
/**
|
|
127
173
|
* Create a worksheet by converting a PDF (URL) into structured questions.
|
|
128
174
|
* Uses the `pdfToWorksheet` pipeline to do inference + persistence.
|
|
@@ -237,24 +283,22 @@ export async function reorderWorksheetQuestions(
|
|
|
237
283
|
message: "Target question not found",
|
|
238
284
|
});
|
|
239
285
|
}
|
|
286
|
+
if (movedId === targetId) {
|
|
287
|
+
throw new TRPCError({
|
|
288
|
+
code: "BAD_REQUEST",
|
|
289
|
+
message: "Moved question and target question must be different",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
240
292
|
|
|
241
293
|
const withoutMoved = questions.filter((q) => q.id !== movedId);
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
...withoutMoved
|
|
251
|
-
.slice(0, targetIdx + 1)
|
|
252
|
-
.map((item) => ({ id: item.id })),
|
|
253
|
-
{ id: movedId },
|
|
254
|
-
...withoutMoved
|
|
255
|
-
.slice(targetIdx + 1)
|
|
256
|
-
.map((item) => ({ id: item.id })),
|
|
257
|
-
];
|
|
294
|
+
const targetIdxWithoutMoved = withoutMoved.findIndex((q) => q.id === targetId);
|
|
295
|
+
const insertIdx =
|
|
296
|
+
position === "before" ? targetIdxWithoutMoved : targetIdxWithoutMoved + 1;
|
|
297
|
+
const next = [
|
|
298
|
+
...withoutMoved.slice(0, insertIdx).map((item) => ({ id: item.id })),
|
|
299
|
+
{ id: movedId },
|
|
300
|
+
...withoutMoved.slice(insertIdx).map((item) => ({ id: item.id })),
|
|
301
|
+
];
|
|
258
302
|
|
|
259
303
|
await prisma.$transaction(
|
|
260
304
|
next.map((item, index) =>
|
|
@@ -386,7 +430,7 @@ export async function answerWorksheetQuestion(
|
|
|
386
430
|
...(isMarkableByAlgo && {
|
|
387
431
|
points: response === correctAnswer ? marksAwardedIfCorrect : 0,
|
|
388
432
|
}),
|
|
389
|
-
status:
|
|
433
|
+
status: JobStatus.NOT_STARTED,
|
|
390
434
|
});
|
|
391
435
|
} else {
|
|
392
436
|
await createStudentQuestionProgress({
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Username generator – produces playful adjective+noun+number combos like "greenegg6767".
|
|
3
|
+
* Pass an `isTaken` checker to retry until a unique username is found.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ADJECTIVES = [
|
|
7
|
+
"green",
|
|
8
|
+
"blue",
|
|
9
|
+
"red",
|
|
10
|
+
"purple",
|
|
11
|
+
"yellow",
|
|
12
|
+
"orange",
|
|
13
|
+
"pink",
|
|
14
|
+
"silver",
|
|
15
|
+
"golden",
|
|
16
|
+
"fuzzy",
|
|
17
|
+
"happy",
|
|
18
|
+
"sleepy",
|
|
19
|
+
"sneaky",
|
|
20
|
+
"silly",
|
|
21
|
+
"brave",
|
|
22
|
+
"clever",
|
|
23
|
+
"lucky",
|
|
24
|
+
"mighty",
|
|
25
|
+
"quiet",
|
|
26
|
+
"quick",
|
|
27
|
+
"loud",
|
|
28
|
+
"tiny",
|
|
29
|
+
"giant",
|
|
30
|
+
"spicy",
|
|
31
|
+
"jolly",
|
|
32
|
+
"wild",
|
|
33
|
+
"shiny",
|
|
34
|
+
"cosmic",
|
|
35
|
+
"mystic",
|
|
36
|
+
"swift",
|
|
37
|
+
"lazy",
|
|
38
|
+
"grumpy",
|
|
39
|
+
"fluffy",
|
|
40
|
+
"zesty",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const NOUNS = [
|
|
44
|
+
"egg",
|
|
45
|
+
"cat",
|
|
46
|
+
"dog",
|
|
47
|
+
"fox",
|
|
48
|
+
"owl",
|
|
49
|
+
"bear",
|
|
50
|
+
"duck",
|
|
51
|
+
"frog",
|
|
52
|
+
"wolf",
|
|
53
|
+
"panda",
|
|
54
|
+
"tiger",
|
|
55
|
+
"otter",
|
|
56
|
+
"moose",
|
|
57
|
+
"shark",
|
|
58
|
+
"whale",
|
|
59
|
+
"raven",
|
|
60
|
+
"robin",
|
|
61
|
+
"koala",
|
|
62
|
+
"llama",
|
|
63
|
+
"sloth",
|
|
64
|
+
"narwhal",
|
|
65
|
+
"penguin",
|
|
66
|
+
"dragon",
|
|
67
|
+
"phoenix",
|
|
68
|
+
"muffin",
|
|
69
|
+
"pickle",
|
|
70
|
+
"noodle",
|
|
71
|
+
"potato",
|
|
72
|
+
"taco",
|
|
73
|
+
"pancake",
|
|
74
|
+
"waffle",
|
|
75
|
+
"cookie",
|
|
76
|
+
"biscuit",
|
|
77
|
+
"donut",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function pick<T>(items: readonly T[]) {
|
|
81
|
+
return items[Math.floor(Math.random() * items.length)]!;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function randomDigits(n: number) {
|
|
85
|
+
let out = "";
|
|
86
|
+
for (let i = 0; i < n; i += 1) {
|
|
87
|
+
out += Math.floor(Math.random() * 10).toString();
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function generateRandomUsername() {
|
|
93
|
+
return `${pick(ADJECTIVES)}${pick(NOUNS)}${randomDigits(4)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Generate a unique username, calling `isTaken` until one is free. */
|
|
97
|
+
export async function generateUniqueUsername(
|
|
98
|
+
isTaken: (username: string) => Promise<boolean>,
|
|
99
|
+
maxAttempts = 10
|
|
100
|
+
) {
|
|
101
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
102
|
+
const candidate = generateRandomUsername();
|
|
103
|
+
if (!(await isTaken(candidate))) {
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return `${generateRandomUsername()}${randomDigits(4)}`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import IORedis from 'ioredis';
|
|
2
|
+
import { env } from '../lib/config/env.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BullMQ-specific Redis connection.
|
|
7
|
+
*
|
|
8
|
+
* Kept separate from `src/lib/redis.ts` (which is used for caching and
|
|
9
|
+
* rate-limiting) because BullMQ requires `maxRetriesPerRequest: null` and
|
|
10
|
+
* `enableReadyCheck: false` on its connection. Mixing those settings with
|
|
11
|
+
* general Redis usage causes unwanted behaviour on transient errors.
|
|
12
|
+
*/
|
|
13
|
+
function buildBullConnection(): IORedis {
|
|
14
|
+
if (!env.REDIS_URL) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'REDIS_URL is required for BullMQ. Set it in .env / .env.production / your hosting env.',
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const connection = new IORedis(env.REDIS_URL, {
|
|
21
|
+
maxRetriesPerRequest: null,
|
|
22
|
+
enableReadyCheck: false,
|
|
23
|
+
lazyConnect: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
connection.on('error', (err) => {
|
|
27
|
+
logger.error('BullMQ Redis connection error', { error: err.message });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
connection.on('connect', () => {
|
|
31
|
+
logger.info('BullMQ Redis connected');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return connection;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const bullConnection = buildBullConnection();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker process entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Run separately from the HTTP server (e.g. `node dist/workers/index.js` on
|
|
5
|
+
* Render/Railway/Fly). Importing this file boots every BullMQ Worker
|
|
6
|
+
* declared under `src/workers/processors/*`. Do NOT import this from the
|
|
7
|
+
* HTTP server — that would spin up workers in your serverless functions.
|
|
8
|
+
*/
|
|
9
|
+
import '../instrument.js';
|
|
10
|
+
|
|
11
|
+
import { Worker } from 'bullmq';
|
|
12
|
+
import { prisma } from '../lib/prisma.js';
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
14
|
+
import { bullConnection } from './connection.js';
|
|
15
|
+
import { pdfToWorksheetWorker } from './processors/pdfToWorksheet.js';
|
|
16
|
+
|
|
17
|
+
const workers: Worker<any>[] = [pdfToWorksheetWorker];
|
|
18
|
+
|
|
19
|
+
logger.info('Workers ready', {
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
queues: workers.map((w) => w.name),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let shuttingDown = false;
|
|
25
|
+
|
|
26
|
+
async function shutdown(signal: string): Promise<void> {
|
|
27
|
+
if (shuttingDown) return;
|
|
28
|
+
shuttingDown = true;
|
|
29
|
+
|
|
30
|
+
logger.info(`Received ${signal}, draining workers...`);
|
|
31
|
+
|
|
32
|
+
// `worker.close()` waits for in-flight jobs to finish (up to the worker's
|
|
33
|
+
// settings) before resolving. Run them in parallel.
|
|
34
|
+
await Promise.allSettled(workers.map((w) => w.close()));
|
|
35
|
+
|
|
36
|
+
await Promise.allSettled([
|
|
37
|
+
bullConnection.quit(),
|
|
38
|
+
prisma.$disconnect(),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
logger.info('Workers shut down cleanly');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
46
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
47
|
+
|
|
48
|
+
process.on('uncaughtException', (error) => {
|
|
49
|
+
logger.error('Uncaught exception in worker process', {
|
|
50
|
+
error: error.message,
|
|
51
|
+
stack: error.stack,
|
|
52
|
+
});
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
process.on('unhandledRejection', (reason) => {
|
|
57
|
+
logger.error('Unhandled rejection in worker process', {
|
|
58
|
+
reason: reason instanceof Error ? reason.message : String(reason),
|
|
59
|
+
});
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|