@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
package/src/lib/config/cors.ts
CHANGED
package/src/lib/fileUpload.ts
CHANGED
|
@@ -189,6 +189,7 @@ export async function createDirectUploadFile(
|
|
|
189
189
|
uploadUrl,
|
|
190
190
|
uploadExpiresAt,
|
|
191
191
|
uploadSessionId,
|
|
192
|
+
uploadedAt: null,
|
|
192
193
|
user: {
|
|
193
194
|
connect: { id: userId }
|
|
194
195
|
},
|
|
@@ -292,6 +293,7 @@ export async function confirmDirectUpload(
|
|
|
292
293
|
const updateData: any = {
|
|
293
294
|
uploadStatus: actualUploadSuccess ? 'COMPLETED' : 'FAILED',
|
|
294
295
|
uploadProgress: actualUploadSuccess ? 100 : 0,
|
|
296
|
+
uploadedAt: actualUploadSuccess ? new Date() : null,
|
|
295
297
|
};
|
|
296
298
|
|
|
297
299
|
if (!actualUploadSuccess && actualErrorMessage) {
|
|
@@ -299,10 +301,6 @@ export async function confirmDirectUpload(
|
|
|
299
301
|
updateData.uploadRetryCount = { increment: 1 };
|
|
300
302
|
}
|
|
301
303
|
|
|
302
|
-
if (actualUploadSuccess) {
|
|
303
|
-
updateData.uploadedAt = new Date();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
304
|
await prisma.file.update({
|
|
307
305
|
where: { id: fileId },
|
|
308
306
|
data: updateData
|
package/src/models/assignment.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Assignment model – assignments, submissions, attachments.
|
|
3
3
|
*/
|
|
4
4
|
import { prisma } from "../lib/prisma.js";
|
|
5
|
+
import { duplicateWorksheetWithClient } from "./worksheet.js";
|
|
5
6
|
import {
|
|
6
7
|
classMemberOrSchoolAdminWhere,
|
|
7
8
|
classTeacherOrSchoolAdminWhere,
|
|
@@ -214,6 +215,137 @@ export function findAssignmentWithClassForTeacher(assignmentId: string, teacherI
|
|
|
214
215
|
});
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Clone an assignment into another class with a new title: copies settings, duplicates linked
|
|
220
|
+
* worksheets into the target class, clones attachment file records (same storage path),
|
|
221
|
+
* creates empty submissions for all students in the target class, and does not copy the
|
|
222
|
+
* calendar event link or per-student “assigned to” overrides.
|
|
223
|
+
*/
|
|
224
|
+
export async function duplicateAssignment(
|
|
225
|
+
sourceAssignmentId: string,
|
|
226
|
+
targetClassId: string,
|
|
227
|
+
title: string,
|
|
228
|
+
duplicatingUserId: string
|
|
229
|
+
) {
|
|
230
|
+
return prisma.$transaction(
|
|
231
|
+
async (tx) => {
|
|
232
|
+
const source = await tx.assignment.findUnique({
|
|
233
|
+
where: { id: sourceAssignmentId },
|
|
234
|
+
include: {
|
|
235
|
+
worksheets: { select: { id: true, name: true } },
|
|
236
|
+
attachments: {
|
|
237
|
+
select: { name: true, type: true, size: true, path: true },
|
|
238
|
+
},
|
|
239
|
+
section: { select: { id: true, name: true } },
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!source) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let targetSectionId: string | undefined;
|
|
248
|
+
if (source.sectionId && source.section) {
|
|
249
|
+
const match = await tx.section.findFirst({
|
|
250
|
+
where: { classId: targetClassId, name: source.section.name },
|
|
251
|
+
select: { id: true },
|
|
252
|
+
});
|
|
253
|
+
targetSectionId = match?.id;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const newWorksheetIds: string[] = [];
|
|
257
|
+
for (const ws of source.worksheets) {
|
|
258
|
+
const dup = await duplicateWorksheetWithClient(
|
|
259
|
+
tx,
|
|
260
|
+
ws.id,
|
|
261
|
+
targetClassId,
|
|
262
|
+
ws.name
|
|
263
|
+
);
|
|
264
|
+
if (dup) {
|
|
265
|
+
newWorksheetIds.push(dup.id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const classData = await tx.class.findUnique({
|
|
270
|
+
where: { id: targetClassId },
|
|
271
|
+
include: { students: { select: { id: true } } },
|
|
272
|
+
});
|
|
273
|
+
if (!classData) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const submissionData = classData.students.map((s) => ({
|
|
278
|
+
student: { connect: { id: s.id } },
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
const created = await tx.assignment.create({
|
|
282
|
+
data: {
|
|
283
|
+
title,
|
|
284
|
+
instructions: source.instructions,
|
|
285
|
+
dueDate: source.dueDate,
|
|
286
|
+
teacher: { connect: { id: duplicatingUserId } },
|
|
287
|
+
class: { connect: { id: targetClassId } },
|
|
288
|
+
acceptFiles: source.acceptFiles,
|
|
289
|
+
acceptExtendedResponse: source.acceptExtendedResponse,
|
|
290
|
+
acceptWorksheet: source.acceptWorksheet,
|
|
291
|
+
gradeWithAI: source.gradeWithAI,
|
|
292
|
+
aiPolicyLevel: source.aiPolicyLevel,
|
|
293
|
+
graded: source.graded,
|
|
294
|
+
maxGrade: source.maxGrade,
|
|
295
|
+
weight: source.weight,
|
|
296
|
+
type: source.type,
|
|
297
|
+
inProgress: source.inProgress,
|
|
298
|
+
template: source.template,
|
|
299
|
+
...(source.markSchemeId && {
|
|
300
|
+
markScheme: { connect: { id: source.markSchemeId } },
|
|
301
|
+
}),
|
|
302
|
+
...(source.gradingBoundaryId && {
|
|
303
|
+
gradingBoundary: { connect: { id: source.gradingBoundaryId } },
|
|
304
|
+
}),
|
|
305
|
+
...(targetSectionId && {
|
|
306
|
+
section: { connect: { id: targetSectionId } },
|
|
307
|
+
}),
|
|
308
|
+
...(newWorksheetIds.length > 0 && {
|
|
309
|
+
worksheets: { connect: newWorksheetIds.map((id) => ({ id })) },
|
|
310
|
+
}),
|
|
311
|
+
submissions: { create: submissionData },
|
|
312
|
+
order: 0,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await tx.assignment.updateMany({
|
|
317
|
+
where: { classId: targetClassId, id: { not: created.id } },
|
|
318
|
+
data: { order: { increment: 1 } },
|
|
319
|
+
});
|
|
320
|
+
await tx.section.updateMany({
|
|
321
|
+
where: { classId: targetClassId },
|
|
322
|
+
data: { order: { increment: 1 } },
|
|
323
|
+
});
|
|
324
|
+
await tx.assignment.update({
|
|
325
|
+
where: { id: created.id },
|
|
326
|
+
data: { order: 1 },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
for (const file of source.attachments) {
|
|
330
|
+
await tx.file.create({
|
|
331
|
+
data: {
|
|
332
|
+
name: file.name,
|
|
333
|
+
type: file.type,
|
|
334
|
+
size: file.size ?? 0,
|
|
335
|
+
path: file.path,
|
|
336
|
+
uploadStatus: "COMPLETED",
|
|
337
|
+
user: { connect: { id: duplicatingUserId } },
|
|
338
|
+
assignment: { connect: { id: created.id } },
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return created;
|
|
344
|
+
},
|
|
345
|
+
{ maxWait: 10000, timeout: 60000 }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
217
349
|
/** @returns Submissions for assignment (teacher view). */
|
|
218
350
|
export function findSubmissionsByAssignmentForTeacher(assignmentId: string, teacherId: string) {
|
|
219
351
|
return prisma.submission.findMany({
|
package/src/models/auth.ts
CHANGED
package/src/models/school.ts
CHANGED
|
@@ -248,20 +248,6 @@ export function removeSchoolMembershipPreservingContent(schoolId: string, userId
|
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
export function findUserByEmailForSchool(email: string) {
|
|
252
|
-
return prisma.user.findFirst({
|
|
253
|
-
where: { email },
|
|
254
|
-
select: { id: true, email: true, username: true },
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function findUserByIdForSchool(userId: string) {
|
|
259
|
-
return prisma.user.findUnique({
|
|
260
|
-
where: { id: userId },
|
|
261
|
-
select: { id: true, email: true, username: true },
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
251
|
export function createSchoolInvite(data: {
|
|
266
252
|
schoolId: string;
|
|
267
253
|
invitedById: string;
|
package/src/models/worksheet.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Worksheet model – worksheets, questions, submissions, student progress, grading.
|
|
3
3
|
*/
|
|
4
|
+
import type { Prisma } from "@prisma/client";
|
|
4
5
|
import { prisma } from "../lib/prisma.js";
|
|
5
|
-
import type { WorksheetQuestionType,
|
|
6
|
+
import type { WorksheetQuestionType, JobStatus } from "@prisma/client";
|
|
6
7
|
import {
|
|
7
8
|
classMemberOrSchoolAdminWhere,
|
|
8
9
|
classTeacherOrSchoolAdminWhere,
|
|
@@ -115,6 +116,59 @@ export function deleteWorksheet(id: string) {
|
|
|
115
116
|
});
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Clone a worksheet and its questions into another class with a new name.
|
|
121
|
+
* Does not copy student responses or assignment links.
|
|
122
|
+
*/
|
|
123
|
+
export async function duplicateWorksheetWithClient(
|
|
124
|
+
tx: Prisma.TransactionClient,
|
|
125
|
+
sourceWorksheetId: string,
|
|
126
|
+
targetClassId: string,
|
|
127
|
+
name: string
|
|
128
|
+
) {
|
|
129
|
+
const source = await tx.worksheet.findUnique({
|
|
130
|
+
where: { id: sourceWorksheetId },
|
|
131
|
+
include: {
|
|
132
|
+
questions: { orderBy: { order: "asc" } },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!source) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const created = await tx.worksheet.create({
|
|
141
|
+
data: { classId: targetClassId, name },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (source.questions.length > 0) {
|
|
145
|
+
await tx.worksheetQuestion.createMany({
|
|
146
|
+
data: source.questions.map((q) => ({
|
|
147
|
+
worksheetId: created.id,
|
|
148
|
+
type: q.type,
|
|
149
|
+
question: q.question,
|
|
150
|
+
answer: q.answer,
|
|
151
|
+
points: q.points,
|
|
152
|
+
options: q.options ?? undefined,
|
|
153
|
+
markScheme: q.markScheme ?? undefined,
|
|
154
|
+
order: q.order,
|
|
155
|
+
})),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return created;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function duplicateWorksheet(
|
|
163
|
+
sourceWorksheetId: string,
|
|
164
|
+
targetClassId: string,
|
|
165
|
+
name: string
|
|
166
|
+
) {
|
|
167
|
+
return prisma.$transaction((tx) =>
|
|
168
|
+
duplicateWorksheetWithClient(tx, sourceWorksheetId, targetClassId, name)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
118
172
|
/** @returns Questions for a worksheet. */
|
|
119
173
|
export function findQuestionsByWorksheetId(worksheetId: string) {
|
|
120
174
|
return prisma.worksheetQuestion.findMany({
|
|
@@ -409,7 +463,7 @@ export function updateStudentQuestionProgress(
|
|
|
409
463
|
response?: string;
|
|
410
464
|
isCorrect?: boolean;
|
|
411
465
|
points?: number;
|
|
412
|
-
status?:
|
|
466
|
+
status?: JobStatus;
|
|
413
467
|
}
|
|
414
468
|
) {
|
|
415
469
|
return prisma.studentQuestionProgress.update({
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { isAIUser } from "../utils/aiUser.js";
|
|
6
6
|
import { prisma } from "../lib/prisma.js";
|
|
7
|
-
import {
|
|
7
|
+
import { JobStatus } from "@prisma/client";
|
|
8
8
|
import { pusher, teacherChannel } from "../lib/pusher.js";
|
|
9
9
|
import type { Assignment, Class, File, Section, User } from "@prisma/client";
|
|
10
10
|
import { inference, sendAIMessage } from "../utils/inference.js";
|
|
@@ -314,7 +314,7 @@ export const getBaseSystemPrompt = (
|
|
|
314
314
|
// content: "GENERATING_CONTENT",
|
|
315
315
|
// senderId: getAIUserId(),
|
|
316
316
|
// conversationId: labChatId,
|
|
317
|
-
// status:
|
|
317
|
+
// status: JobStatus.PENDING,
|
|
318
318
|
// },
|
|
319
319
|
// });
|
|
320
320
|
|
|
@@ -599,7 +599,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
599
599
|
if (emitOptions) {
|
|
600
600
|
await prisma.message.update({
|
|
601
601
|
where: { id: emitOptions.messageId },
|
|
602
|
-
data: { status:
|
|
602
|
+
data: { status: JobStatus.COMPLETED },
|
|
603
603
|
});
|
|
604
604
|
try {
|
|
605
605
|
await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-completed", {
|
|
@@ -628,7 +628,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
628
628
|
try {
|
|
629
629
|
await prisma.message.update({
|
|
630
630
|
where: { id: emitOptions.messageId },
|
|
631
|
-
data: { status:
|
|
631
|
+
data: { status: JobStatus.FAILED },
|
|
632
632
|
});
|
|
633
633
|
} catch (statusError) {
|
|
634
634
|
logger.error("Failed to set message status FAILED:", {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Grade worksheet pipeline – AI-powered grading of worksheet responses.
|
|
3
3
|
* Grades questions via inference API, broadcasts status via Pusher (pending/completed/failed/cancelled).
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { JobStatus, WorksheetQuestionType } from "@prisma/client";
|
|
6
6
|
import { prisma } from "../lib/prisma.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
import z from "zod";
|
|
@@ -113,11 +113,11 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
113
113
|
const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
|
|
114
114
|
where: { id: studentQuestionProgress.id, status: {
|
|
115
115
|
not: {
|
|
116
|
-
in: [
|
|
116
|
+
in: [JobStatus.CANCELLED],
|
|
117
117
|
},
|
|
118
118
|
} },
|
|
119
119
|
data: {
|
|
120
|
-
status:
|
|
120
|
+
status: JobStatus.COMPLETED,
|
|
121
121
|
isCorrect: (apiResponse as { isCorrect: boolean }).isCorrect,
|
|
122
122
|
points: (apiResponse as { points: number }).points,
|
|
123
123
|
markschemeState: (apiResponse as {
|
|
@@ -143,13 +143,23 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
143
143
|
return updatedStudentQuestionProgress;
|
|
144
144
|
} catch (error) {
|
|
145
145
|
logger.error('Failed to grade worksheet response', { error, worksheetResponseId });
|
|
146
|
+
const { count } = await prisma.studentQuestionProgress.updateMany({
|
|
147
|
+
where: {
|
|
148
|
+
id: studentQuestionProgress.id,
|
|
149
|
+
status: { not: JobStatus.CANCELLED },
|
|
150
|
+
},
|
|
151
|
+
data: { status: JobStatus.FAILED },
|
|
152
|
+
});
|
|
153
|
+
if (count === 0) {
|
|
154
|
+
logger.info('Skipping failed status update for cancelled worksheet question', {
|
|
155
|
+
worksheetResponseId,
|
|
156
|
+
worksheetQuestionProgressId,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
146
160
|
pusher.trigger(worksheetChannel(worksheetResponse.id), `set-failed`, {
|
|
147
161
|
id: studentQuestionProgress.id,
|
|
148
162
|
});
|
|
149
|
-
await prisma.studentQuestionProgress.update({
|
|
150
|
-
where: { id: studentQuestionProgress.id },
|
|
151
|
-
data: { status: GenerationStatus.FAILED },
|
|
152
|
-
});
|
|
153
163
|
throw error;
|
|
154
164
|
}
|
|
155
165
|
}
|
|
@@ -160,7 +170,7 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
160
170
|
* @returns updated worksheet response
|
|
161
171
|
*/
|
|
162
172
|
|
|
163
|
-
const DO_NOT_INFERENCE_STATUSES = [
|
|
173
|
+
const DO_NOT_INFERENCE_STATUSES = [JobStatus.CANCELLED, JobStatus.PENDING, JobStatus.COMPLETED];
|
|
164
174
|
|
|
165
175
|
export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
|
|
166
176
|
logger.info('Grading worksheet response', { worksheetResponseId });
|
|
@@ -200,17 +210,20 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
|
|
|
200
210
|
for (const response of worksheetResponse.responses) {
|
|
201
211
|
logger.info('Grading question', { questionId: response.questionId });
|
|
202
212
|
|
|
203
|
-
const
|
|
204
|
-
where: {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
const { count } = await prisma.studentQuestionProgress.updateMany({
|
|
214
|
+
where: {
|
|
215
|
+
id: response.id,
|
|
216
|
+
status: {
|
|
217
|
+
not: {
|
|
218
|
+
in: DO_NOT_INFERENCE_STATUSES,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
data: { status: JobStatus.PENDING },
|
|
210
223
|
});
|
|
211
224
|
|
|
212
|
-
if (
|
|
213
|
-
|
|
225
|
+
if (count === 0) {
|
|
226
|
+
continue;
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
await gradeWorksheetQuestion(worksheetResponseId, response.id);
|
|
@@ -233,7 +246,7 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
|
|
|
233
246
|
}
|
|
234
247
|
const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
|
|
235
248
|
where: { id: worksheetQuestionProgressId },
|
|
236
|
-
data: { status:
|
|
249
|
+
data: { status: JobStatus.CANCELLED },
|
|
237
250
|
});
|
|
238
251
|
|
|
239
252
|
await removeAllPreviousAIComments(worksheetQuestionProgressId);
|
|
@@ -255,26 +268,51 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
|
|
|
255
268
|
},
|
|
256
269
|
});
|
|
257
270
|
|
|
258
|
-
await removeAllPreviousAIComments(worksheetQuestionProgressId);
|
|
259
|
-
|
|
260
271
|
if (!worksheetResponse) {
|
|
261
272
|
logger.error('Worksheet response not found');
|
|
262
273
|
throw new Error('Worksheet response not found');
|
|
263
274
|
}
|
|
264
275
|
|
|
265
|
-
const
|
|
276
|
+
const { count } = await prisma.studentQuestionProgress.updateMany({
|
|
277
|
+
where: {
|
|
278
|
+
id: worksheetQuestionProgressId,
|
|
279
|
+
status: { not: JobStatus.CANCELLED },
|
|
280
|
+
},
|
|
281
|
+
data: { status: JobStatus.PENDING },
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (count === 0) {
|
|
285
|
+
logger.info('Skipping regrade for cancelled worksheet question', {
|
|
286
|
+
worksheetResponseId,
|
|
287
|
+
worksheetQuestionProgressId,
|
|
288
|
+
});
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.findUnique({
|
|
266
293
|
where: { id: worksheetQuestionProgressId },
|
|
267
|
-
data: { status: GenerationStatus.PENDING },
|
|
268
294
|
});
|
|
269
295
|
|
|
296
|
+
await removeAllPreviousAIComments(worksheetQuestionProgressId);
|
|
297
|
+
|
|
270
298
|
await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
|
|
271
299
|
|
|
272
300
|
return updatedStudentQuestionProgress;
|
|
273
301
|
} catch (error) {
|
|
274
|
-
await prisma.studentQuestionProgress.
|
|
275
|
-
where: {
|
|
276
|
-
|
|
302
|
+
const { count } = await prisma.studentQuestionProgress.updateMany({
|
|
303
|
+
where: {
|
|
304
|
+
id: worksheetQuestionProgressId,
|
|
305
|
+
status: { not: JobStatus.CANCELLED },
|
|
306
|
+
},
|
|
307
|
+
data: { status: JobStatus.FAILED },
|
|
277
308
|
});
|
|
309
|
+
if (count === 0) {
|
|
310
|
+
logger.info('Skipping failed status update for cancelled worksheet question regrade', {
|
|
311
|
+
worksheetResponseId,
|
|
312
|
+
worksheetQuestionProgressId,
|
|
313
|
+
});
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
278
316
|
const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
|
|
279
317
|
where: { id: worksheetResponseId, },
|
|
280
318
|
include: {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { ResponseInput } from "openai/resources/responses/responses.js";
|
|
7
|
+
import { Prisma } from "@prisma/client";
|
|
7
8
|
import { prisma } from "../lib/prisma.js";
|
|
8
9
|
import { inference } from "../utils/inference.js";
|
|
9
10
|
import { logger } from "../utils/logger.js";
|
|
@@ -109,15 +110,33 @@ If no answer is provided for a multiple-choice or true/false question, determine
|
|
|
109
110
|
* structured inference, and inserts worksheet + questions in one DB transaction.
|
|
110
111
|
*/
|
|
111
112
|
export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
112
|
-
const
|
|
113
|
+
const sourceUrl = sanitizeUrlForLogs(gcsUrl);
|
|
113
114
|
|
|
114
115
|
if (!isProbablyGoogleCloudUrl(gcsUrl)) {
|
|
115
116
|
logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
|
|
116
|
-
sanitizedUrl,
|
|
117
|
+
sanitizedUrl: sourceUrl,
|
|
117
118
|
});
|
|
118
119
|
throw new Error("Only Google Cloud Storage URLs are supported");
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
const existingWorksheet = await prisma.worksheet.findUnique({
|
|
123
|
+
where: {
|
|
124
|
+
classId_sourceUrl: {
|
|
125
|
+
classId,
|
|
126
|
+
sourceUrl,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (existingWorksheet) {
|
|
132
|
+
logger.info("pdfToWorksheet: reusing existing worksheet for source PDF", {
|
|
133
|
+
worksheetId: existingWorksheet.id,
|
|
134
|
+
classId,
|
|
135
|
+
sourceUrl,
|
|
136
|
+
});
|
|
137
|
+
return existingWorksheet;
|
|
138
|
+
}
|
|
139
|
+
|
|
121
140
|
const filename = getFilenameFromUrl(gcsUrl);
|
|
122
141
|
const prompt = `Read the attached PDF and convert it into a single worksheet JSON object.\n\n${schemaInstructions}`;
|
|
123
142
|
|
|
@@ -149,29 +168,56 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
|
149
168
|
return null;
|
|
150
169
|
}
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
try {
|
|
172
|
+
return await prisma.$transaction(async (tx) => {
|
|
173
|
+
const worksheet = await tx.worksheet.create({
|
|
174
|
+
data: { classId, name: draft.title, sourceUrl },
|
|
175
|
+
});
|
|
156
176
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
177
|
+
if (draft.questions.length > 0) {
|
|
178
|
+
await tx.worksheetQuestion.createMany({
|
|
179
|
+
data: draft.questions.map((question) => ({
|
|
180
|
+
worksheetId: worksheet.id,
|
|
181
|
+
type: question.type,
|
|
182
|
+
question: question.question,
|
|
183
|
+
answer: question.answer,
|
|
184
|
+
points: Math.round(question.points ?? 0),
|
|
185
|
+
options: question.options ?? [],
|
|
186
|
+
markScheme: (question.markScheme ?? []).map((item) => ({
|
|
187
|
+
...item,
|
|
188
|
+
points: Math.round(item.points),
|
|
189
|
+
})),
|
|
190
|
+
order: Math.round(question.order),
|
|
169
191
|
})),
|
|
170
|
-
|
|
171
|
-
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return worksheet;
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (
|
|
199
|
+
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
200
|
+
error.code === "P2002"
|
|
201
|
+
) {
|
|
202
|
+
const worksheet = await prisma.worksheet.findUnique({
|
|
203
|
+
where: {
|
|
204
|
+
classId_sourceUrl: {
|
|
205
|
+
classId,
|
|
206
|
+
sourceUrl,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
172
209
|
});
|
|
210
|
+
|
|
211
|
+
if (worksheet) {
|
|
212
|
+
logger.info("pdfToWorksheet: reused worksheet created by concurrent retry", {
|
|
213
|
+
worksheetId: worksheet.id,
|
|
214
|
+
classId,
|
|
215
|
+
sourceUrl,
|
|
216
|
+
});
|
|
217
|
+
return worksheet;
|
|
218
|
+
}
|
|
173
219
|
}
|
|
174
220
|
|
|
175
|
-
|
|
176
|
-
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
177
223
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getSubmissionById,
|
|
19
19
|
getSubmissions,
|
|
20
20
|
createAssignmentRecord,
|
|
21
|
+
duplicateAssignmentRecord,
|
|
21
22
|
updateAssignmentRecord,
|
|
22
23
|
deleteAssignmentRecord,
|
|
23
24
|
updateSubmissionRecord,
|
|
@@ -273,6 +274,24 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
273
274
|
create: protectedTeacherProcedure
|
|
274
275
|
.input(createAssignmentSchema)
|
|
275
276
|
.mutation(({ ctx, input }) => createAssignmentRecord(ctx.user!.id, input)),
|
|
277
|
+
|
|
278
|
+
duplicateAssignment: protectedTeacherProcedure
|
|
279
|
+
.input(
|
|
280
|
+
z.object({
|
|
281
|
+
classId: z.string(),
|
|
282
|
+
title: z.string(),
|
|
283
|
+
assignmentId: z.string(),
|
|
284
|
+
})
|
|
285
|
+
)
|
|
286
|
+
.mutation(({ ctx, input }) =>
|
|
287
|
+
duplicateAssignmentRecord(
|
|
288
|
+
input.assignmentId,
|
|
289
|
+
input.classId,
|
|
290
|
+
input.title,
|
|
291
|
+
ctx.user!.id
|
|
292
|
+
)
|
|
293
|
+
),
|
|
294
|
+
|
|
276
295
|
update: protectedTeacherProcedure
|
|
277
296
|
.input(updateAssignmentSchema)
|
|
278
297
|
.mutation(({ ctx, input }) => updateAssignmentRecord(ctx.user!.id, input)),
|
package/src/routers/school.ts
CHANGED
|
@@ -104,6 +104,11 @@ export const schoolRouter = createTRPCRouter({
|
|
|
104
104
|
schoolId: z.string(),
|
|
105
105
|
email: z.string().email(),
|
|
106
106
|
role: schoolRoleSchema,
|
|
107
|
+
username: z
|
|
108
|
+
.string()
|
|
109
|
+
.min(3, "Username must be at least 3 characters")
|
|
110
|
+
.optional(),
|
|
111
|
+
displayName: z.string().min(1).optional(),
|
|
107
112
|
})
|
|
108
113
|
)
|
|
109
114
|
.mutation(({ ctx, input }) => createInvite(ctx.user!.id, input)),
|
package/src/routers/worksheet.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
updateWorksheetRecord,
|
|
20
20
|
deleteWorksheetRecord,
|
|
21
21
|
createWorksheetRecord,
|
|
22
|
+
duplicateWorksheetRecord,
|
|
22
23
|
createWorksheetFromPdf,
|
|
23
24
|
createWorksheetFromUploadedPdf,
|
|
24
25
|
addQuestionToWorksheet,
|
|
@@ -108,6 +109,23 @@ export const worksheetRouter = createTRPCRouter({
|
|
|
108
109
|
createWorksheetRecord(input.classId, input.name)
|
|
109
110
|
),
|
|
110
111
|
|
|
112
|
+
duplicateWorksheet: protectedTeacherProcedure
|
|
113
|
+
.input(
|
|
114
|
+
z.object({
|
|
115
|
+
classId: z.string(),
|
|
116
|
+
name: z.string(),
|
|
117
|
+
worksheetId: z.string(),
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
.mutation(({ ctx, input }) =>
|
|
121
|
+
duplicateWorksheetRecord(
|
|
122
|
+
input.worksheetId,
|
|
123
|
+
input.classId,
|
|
124
|
+
input.name,
|
|
125
|
+
ctx.user!.id
|
|
126
|
+
)
|
|
127
|
+
),
|
|
128
|
+
|
|
111
129
|
createFromPdf: protectedTeacherProcedure
|
|
112
130
|
.input(z.object({ classId: z.string(), url: z.string().url() }))
|
|
113
131
|
.mutation(({ input }) => createWorksheetFromPdf(input.classId, input.url)),
|