@studious-lms/server 1.4.9 → 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/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/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 +76 -19
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- 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 +92 -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 +0 -8
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/auth.d.ts +1 -0
- package/dist/routers/auth.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 +35 -7
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.d.ts.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 +35 -7
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +17 -19
- 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/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/auth.ts +1 -0
- package/src/models/school.ts +0 -14
- package/src/models/worksheet.ts +2 -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/school.ts +5 -0
- 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 +16 -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/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/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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { Prisma } from "@prisma/client";
|
|
5
5
|
import { prisma } from "../lib/prisma.js";
|
|
6
|
-
import type { WorksheetQuestionType,
|
|
6
|
+
import type { WorksheetQuestionType, JobStatus } from "@prisma/client";
|
|
7
7
|
import {
|
|
8
8
|
classMemberOrSchoolAdminWhere,
|
|
9
9
|
classTeacherOrSchoolAdminWhere,
|
|
@@ -463,7 +463,7 @@ export function updateStudentQuestionProgress(
|
|
|
463
463
|
response?: string;
|
|
464
464
|
isCorrect?: boolean;
|
|
465
465
|
points?: number;
|
|
466
|
-
status?:
|
|
466
|
+
status?: JobStatus;
|
|
467
467
|
}
|
|
468
468
|
) {
|
|
469
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
|
}
|
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/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";
|
|
@@ -283,24 +283,22 @@ export async function reorderWorksheetQuestions(
|
|
|
283
283
|
message: "Target question not found",
|
|
284
284
|
});
|
|
285
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
|
+
}
|
|
286
292
|
|
|
287
293
|
const withoutMoved = questions.filter((q) => q.id !== movedId);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
...withoutMoved
|
|
297
|
-
.slice(0, targetIdx + 1)
|
|
298
|
-
.map((item) => ({ id: item.id })),
|
|
299
|
-
{ id: movedId },
|
|
300
|
-
...withoutMoved
|
|
301
|
-
.slice(targetIdx + 1)
|
|
302
|
-
.map((item) => ({ id: item.id })),
|
|
303
|
-
];
|
|
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
|
+
];
|
|
304
302
|
|
|
305
303
|
await prisma.$transaction(
|
|
306
304
|
next.map((item, index) =>
|
|
@@ -432,7 +430,7 @@ export async function answerWorksheetQuestion(
|
|
|
432
430
|
...(isMarkableByAlgo && {
|
|
433
431
|
points: response === correctAnswer ? marksAwardedIfCorrect : 0,
|
|
434
432
|
}),
|
|
435
|
-
status:
|
|
433
|
+
status: JobStatus.NOT_STARTED,
|
|
436
434
|
});
|
|
437
435
|
} else {
|
|
438
436
|
await createStudentQuestionProgress({
|