@studious-lms/server 1.4.9 → 1.6.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/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +2 -3
- 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/lib/pusher.d.ts +7 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +9 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/attendance.d.ts +16 -7
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -2
- package/dist/models/attendance.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 +17 -16
- 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.d.ts +20 -9
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +63 -75
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts +13 -4
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +39 -27
- package/dist/pipelines/aiNewtonChat.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 +34 -2
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +205 -32
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +106 -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/assignment.js +64 -4
- 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/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 +3 -1
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/message.d.ts +3 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/newtonChat.d.ts +1 -0
- package/dist/routers/newtonChat.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 +37 -7
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +16 -6
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/announcement.js +4 -4
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +16 -6
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +68 -4
- package/dist/services/attendance.js.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 +10 -2
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +103 -44
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +3 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +20 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +1 -0
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +40 -17
- package/dist/services/newtonChat.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 +48 -11
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +73 -29
- 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/utils/inference.d.ts +50 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +167 -2
- package/dist/utils/inference.js.map +1 -1
- 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 +63 -0
- package/dist/workers/index.js.map +1 -0
- package/dist/workers/processors/aiLabChat.d.ts +7 -0
- package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
- package/dist/workers/processors/aiLabChat.js +52 -0
- package/dist/workers/processors/aiLabChat.js.map +1 -0
- package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
- package/dist/workers/processors/aiNewtonChat.js +57 -0
- package/dist/workers/processors/aiNewtonChat.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
- package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +118 -0
- package/dist/workers/processors/createRunJobWorker.js.map +1 -0
- package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
- package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/gradeWorksheet.js +19 -0
- package/dist/workers/processors/gradeWorksheet.js.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts +8 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.js +20 -0
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
- package/dist/workers/processors/studentProgressChat.d.ts +8 -0
- package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
- package/dist/workers/processors/studentProgressChat.js +27 -0
- package/dist/workers/processors/studentProgressChat.js.map +1 -0
- package/dist/workers/queues.d.ts +85 -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 +6 -3
- package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
- package/prisma/schema.prisma +465 -387
- package/src/index.ts +12 -1
- package/src/lib/config/cors.ts +0 -1
- package/src/lib/fileUpload.ts +2 -4
- package/src/lib/pusher.ts +7 -1
- package/src/models/attendance.ts +15 -0
- 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 +71 -79
- package/src/pipelines/aiNewtonChat.ts +47 -31
- package/src/pipelines/gradeWorksheet.ts +63 -25
- package/src/pipelines/pdfToWorksheet.ts +272 -34
- package/src/routers/assignment.ts +74 -3
- package/src/routers/school.ts +5 -0
- package/src/routers/worksheet.ts +25 -4
- package/src/services/announcement.ts +2 -2
- package/src/services/assignment.ts +18 -4
- package/src/services/attendance.ts +87 -2
- package/src/services/labChat.ts +108 -48
- package/src/services/message.ts +19 -0
- package/src/services/newtonChat.ts +46 -25
- package/src/services/school.ts +89 -6
- package/src/services/worksheet.ts +87 -28
- package/src/utils/generateUsername.ts +108 -0
- package/src/utils/inference.ts +209 -1
- package/src/workers/connection.ts +37 -0
- package/src/workers/index.ts +71 -0
- package/src/workers/processors/aiLabChat.ts +65 -0
- package/src/workers/processors/aiNewtonChat.ts +75 -0
- package/src/workers/processors/createRunJobWorker.ts +164 -0
- package/src/workers/processors/gradeWorksheet.ts +16 -0
- package/src/workers/processors/pdfToWorksheet.ts +17 -0
- package/src/workers/processors/studentProgressChat.ts +28 -0
- package/src/workers/queues.ts +174 -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 +61 -2
- 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/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,11 +3,12 @@
|
|
|
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";
|
|
10
|
-
import {
|
|
10
|
+
import { enqueueRunJob } from "../workers/queues.js";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
11
12
|
import {
|
|
12
13
|
findWorksheetByIdMinimal,
|
|
13
14
|
findWorksheetByIdForClassMember,
|
|
@@ -170,21 +171,77 @@ export async function duplicateWorksheetRecord(
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
174
|
+
* Kick off PDF-to-worksheet conversion asynchronously.
|
|
175
|
+
*
|
|
176
|
+
* We synchronously create a placeholder Worksheet row (status PENDING) so the
|
|
177
|
+
* caller has an id to navigate to immediately, then enqueue a BullMQ job that
|
|
178
|
+
* fills in the real title and questions. Progress is broadcast on the
|
|
179
|
+
* per-worksheet job Pusher channel; clients should subscribe there to drive
|
|
180
|
+
* loading UI.
|
|
175
181
|
*/
|
|
176
182
|
export async function createWorksheetFromPdf(
|
|
177
183
|
classId: string,
|
|
178
|
-
url: string
|
|
184
|
+
url: string,
|
|
185
|
+
userId?: string,
|
|
186
|
+
options: { name?: string } = {},
|
|
179
187
|
) {
|
|
180
|
-
const
|
|
181
|
-
|
|
188
|
+
const placeholder = await prisma.worksheet.create({
|
|
189
|
+
data: {
|
|
190
|
+
classId,
|
|
191
|
+
name: options.name?.trim() || "Converting PDF…",
|
|
192
|
+
status: JobStatus.PENDING,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const { runJobId } = await enqueueRunJob(
|
|
198
|
+
"PDF_TO_WORKSHEET",
|
|
199
|
+
{ gcsUrl: url, classId, worksheetId: placeholder.id },
|
|
200
|
+
{ classId, userId: userId ?? null },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await prisma.worksheet
|
|
204
|
+
.update({
|
|
205
|
+
where: { id: placeholder.id },
|
|
206
|
+
data: { runJobId },
|
|
207
|
+
})
|
|
208
|
+
.catch((linkError) => {
|
|
209
|
+
logger.error(
|
|
210
|
+
"createWorksheetFromPdf: failed to link RunJob to Worksheet",
|
|
211
|
+
{
|
|
212
|
+
worksheetId: placeholder.id,
|
|
213
|
+
runJobId,
|
|
214
|
+
error:
|
|
215
|
+
linkError instanceof Error
|
|
216
|
+
? linkError.message
|
|
217
|
+
: String(linkError),
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...placeholder,
|
|
224
|
+
runJobId,
|
|
225
|
+
};
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await prisma.worksheet
|
|
228
|
+
.update({
|
|
229
|
+
where: { id: placeholder.id },
|
|
230
|
+
data: { status: JobStatus.FAILED },
|
|
231
|
+
})
|
|
232
|
+
.catch(() => undefined);
|
|
233
|
+
|
|
234
|
+
logger.error("createWorksheetFromPdf: failed to enqueue conversion", {
|
|
235
|
+
worksheetId: placeholder.id,
|
|
236
|
+
classId,
|
|
237
|
+
error: error instanceof Error ? error.message : String(error),
|
|
238
|
+
});
|
|
239
|
+
|
|
182
240
|
throw new TRPCError({
|
|
183
241
|
code: "INTERNAL_SERVER_ERROR",
|
|
184
|
-
message: "Failed to
|
|
242
|
+
message: "Failed to start worksheet conversion",
|
|
185
243
|
});
|
|
186
244
|
}
|
|
187
|
-
return created;
|
|
188
245
|
}
|
|
189
246
|
|
|
190
247
|
/**
|
|
@@ -193,7 +250,8 @@ export async function createWorksheetFromPdf(
|
|
|
193
250
|
export async function createWorksheetFromUploadedPdf(
|
|
194
251
|
classId: string,
|
|
195
252
|
fileId: string,
|
|
196
|
-
userId: string
|
|
253
|
+
userId: string,
|
|
254
|
+
options: { name?: string } = {},
|
|
197
255
|
) {
|
|
198
256
|
const file = await prisma.file.findFirst({
|
|
199
257
|
where: {
|
|
@@ -205,6 +263,7 @@ export async function createWorksheetFromUploadedPdf(
|
|
|
205
263
|
id: true,
|
|
206
264
|
path: true,
|
|
207
265
|
type: true,
|
|
266
|
+
name: true,
|
|
208
267
|
},
|
|
209
268
|
});
|
|
210
269
|
|
|
@@ -225,7 +284,9 @@ export async function createWorksheetFromUploadedPdf(
|
|
|
225
284
|
}
|
|
226
285
|
|
|
227
286
|
const signedUrl = await getFileUrl(file.path);
|
|
228
|
-
return createWorksheetFromPdf(classId, signedUrl
|
|
287
|
+
return createWorksheetFromPdf(classId, signedUrl, userId, {
|
|
288
|
+
name: options.name ?? file.name ?? undefined,
|
|
289
|
+
});
|
|
229
290
|
}
|
|
230
291
|
|
|
231
292
|
export async function addQuestionToWorksheet(
|
|
@@ -283,24 +344,22 @@ export async function reorderWorksheetQuestions(
|
|
|
283
344
|
message: "Target question not found",
|
|
284
345
|
});
|
|
285
346
|
}
|
|
347
|
+
if (movedId === targetId) {
|
|
348
|
+
throw new TRPCError({
|
|
349
|
+
code: "BAD_REQUEST",
|
|
350
|
+
message: "Moved question and target question must be different",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
286
353
|
|
|
287
354
|
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
|
-
];
|
|
355
|
+
const targetIdxWithoutMoved = withoutMoved.findIndex((q) => q.id === targetId);
|
|
356
|
+
const insertIdx =
|
|
357
|
+
position === "before" ? targetIdxWithoutMoved : targetIdxWithoutMoved + 1;
|
|
358
|
+
const next = [
|
|
359
|
+
...withoutMoved.slice(0, insertIdx).map((item) => ({ id: item.id })),
|
|
360
|
+
{ id: movedId },
|
|
361
|
+
...withoutMoved.slice(insertIdx).map((item) => ({ id: item.id })),
|
|
362
|
+
];
|
|
304
363
|
|
|
305
364
|
await prisma.$transaction(
|
|
306
365
|
next.map((item, index) =>
|
|
@@ -432,7 +491,7 @@ export async function answerWorksheetQuestion(
|
|
|
432
491
|
...(isMarkableByAlgo && {
|
|
433
492
|
points: response === correctAnswer ? marksAwardedIfCorrect : 0,
|
|
434
493
|
}),
|
|
435
|
-
status:
|
|
494
|
+
status: JobStatus.NOT_STARTED,
|
|
436
495
|
});
|
|
437
496
|
} else {
|
|
438
497
|
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
|
+
}
|
package/src/utils/inference.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { prisma } from '../lib/prisma.js';
|
|
|
8
8
|
import { chatChannel, pusher } from '../lib/pusher.js';
|
|
9
9
|
import { ensureAIUserExists, getAIUserId } from './aiUser.js';
|
|
10
10
|
import { env } from '../lib/config/env.js';
|
|
11
|
-
import { Prisma } from '@prisma/client';
|
|
11
|
+
import { JobStatus, Prisma } from '@prisma/client';
|
|
12
12
|
import { ZodSchema } from 'zod';
|
|
13
13
|
import { zodTextFormat } from 'openai/helpers/zod';
|
|
14
14
|
|
|
@@ -276,6 +276,214 @@ export async function sendAIMessage(
|
|
|
276
276
|
};
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
interface AIMessageSenderInfo {
|
|
280
|
+
displayName?: string;
|
|
281
|
+
profilePicture?: string | null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildAISenderInfo(customSender?: AIMessageSenderInfo) {
|
|
285
|
+
return {
|
|
286
|
+
id: getAIUserId(),
|
|
287
|
+
username: 'Newton_AI',
|
|
288
|
+
profile: {
|
|
289
|
+
displayName: customSender?.displayName ?? 'Newton AI',
|
|
290
|
+
profilePicture: customSender?.profilePicture ?? null,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create an AI message placeholder in PENDING state and broadcast it.
|
|
297
|
+
*
|
|
298
|
+
* The AI message lifecycle == job lifecycle: this row is the bubble the
|
|
299
|
+
* client renders for "AI is thinking..." and is later flipped by
|
|
300
|
+
* {@link completePendingAIMessage} / {@link failPendingAIMessage}.
|
|
301
|
+
*/
|
|
302
|
+
export async function createPendingAIMessage(
|
|
303
|
+
conversationId: string,
|
|
304
|
+
options: {
|
|
305
|
+
customSender?: AIMessageSenderInfo;
|
|
306
|
+
} = {},
|
|
307
|
+
): Promise<{
|
|
308
|
+
id: string;
|
|
309
|
+
conversationId: string;
|
|
310
|
+
createdAt: Date;
|
|
311
|
+
senderId: string;
|
|
312
|
+
status: JobStatus;
|
|
313
|
+
}> {
|
|
314
|
+
await ensureAIUserExists();
|
|
315
|
+
|
|
316
|
+
const aiMessage = await prisma.message.create({
|
|
317
|
+
data: {
|
|
318
|
+
content: '',
|
|
319
|
+
senderId: getAIUserId(),
|
|
320
|
+
conversationId,
|
|
321
|
+
status: JobStatus.PENDING,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
logger.info('AI placeholder message created', {
|
|
326
|
+
messageId: aiMessage.id,
|
|
327
|
+
conversationId,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const senderInfo = buildAISenderInfo(options.customSender);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await pusher.trigger(chatChannel(conversationId), 'new-message', {
|
|
334
|
+
id: aiMessage.id,
|
|
335
|
+
content: '',
|
|
336
|
+
senderId: getAIUserId(),
|
|
337
|
+
conversationId: aiMessage.conversationId,
|
|
338
|
+
status: JobStatus.PENDING,
|
|
339
|
+
createdAt: aiMessage.createdAt,
|
|
340
|
+
sender: senderInfo,
|
|
341
|
+
mentionedUserIds: [],
|
|
342
|
+
meta: {},
|
|
343
|
+
attachments: [],
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
logger.error('Failed to broadcast pending AI message:', {
|
|
347
|
+
error,
|
|
348
|
+
messageId: aiMessage.id,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
id: aiMessage.id,
|
|
354
|
+
conversationId: aiMessage.conversationId,
|
|
355
|
+
createdAt: aiMessage.createdAt,
|
|
356
|
+
senderId: aiMessage.senderId,
|
|
357
|
+
status: JobStatus.PENDING,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Fill a PENDING AI message with its generated content and flip it to
|
|
363
|
+
* COMPLETED. Broadcasts a `message-updated` event so clients that already
|
|
364
|
+
* rendered the placeholder can patch it in place.
|
|
365
|
+
*/
|
|
366
|
+
export async function completePendingAIMessage(
|
|
367
|
+
messageId: string,
|
|
368
|
+
content: string,
|
|
369
|
+
options: {
|
|
370
|
+
attachments?: { connect: { id: string }[] };
|
|
371
|
+
meta?: Record<string, any>;
|
|
372
|
+
customSender?: AIMessageSenderInfo;
|
|
373
|
+
} = {},
|
|
374
|
+
): Promise<{
|
|
375
|
+
id: string;
|
|
376
|
+
content: string;
|
|
377
|
+
conversationId: string;
|
|
378
|
+
createdAt: Date;
|
|
379
|
+
status: JobStatus;
|
|
380
|
+
}> {
|
|
381
|
+
const updated = await prisma.message.update({
|
|
382
|
+
where: { id: messageId },
|
|
383
|
+
data: {
|
|
384
|
+
content,
|
|
385
|
+
status: JobStatus.COMPLETED,
|
|
386
|
+
...(options.attachments && {
|
|
387
|
+
attachments: {
|
|
388
|
+
connect: options.attachments.connect,
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
...(options.meta !== undefined && {
|
|
392
|
+
meta: options.meta as Prisma.InputJsonValue,
|
|
393
|
+
}),
|
|
394
|
+
},
|
|
395
|
+
include: {
|
|
396
|
+
attachments: true,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
logger.info('AI message completed', {
|
|
401
|
+
messageId: updated.id,
|
|
402
|
+
conversationId: updated.conversationId,
|
|
403
|
+
contentLength: content.length,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const senderInfo = buildAISenderInfo(options.customSender);
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
|
|
410
|
+
id: updated.id,
|
|
411
|
+
content: updated.content,
|
|
412
|
+
senderId: updated.senderId,
|
|
413
|
+
conversationId: updated.conversationId,
|
|
414
|
+
status: updated.status,
|
|
415
|
+
createdAt: updated.createdAt,
|
|
416
|
+
sender: senderInfo,
|
|
417
|
+
mentionedUserIds: [],
|
|
418
|
+
meta: updated.meta ?? {},
|
|
419
|
+
attachments: updated.attachments.map((attachment) => ({
|
|
420
|
+
id: attachment.id,
|
|
421
|
+
attachmentId: attachment.id,
|
|
422
|
+
name: attachment.name,
|
|
423
|
+
type: attachment.type,
|
|
424
|
+
size: attachment.size,
|
|
425
|
+
path: attachment.path,
|
|
426
|
+
})),
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
logger.error('Failed to broadcast completed AI message:', {
|
|
430
|
+
error,
|
|
431
|
+
messageId,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
id: updated.id,
|
|
437
|
+
content: updated.content,
|
|
438
|
+
conversationId: updated.conversationId,
|
|
439
|
+
createdAt: updated.createdAt,
|
|
440
|
+
status: (updated.status ?? JobStatus.COMPLETED) as JobStatus,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Flip a PENDING AI message to FAILED and broadcast the status change.
|
|
446
|
+
* Does NOT throw — failure paths in the pipelines call this in catch blocks
|
|
447
|
+
* and we do not want broadcast/DB errors to mask the original error.
|
|
448
|
+
*/
|
|
449
|
+
export async function failPendingAIMessage(
|
|
450
|
+
messageId: string,
|
|
451
|
+
options: { reason?: string } = {},
|
|
452
|
+
): Promise<void> {
|
|
453
|
+
try {
|
|
454
|
+
const updated = await prisma.message.update({
|
|
455
|
+
where: { id: messageId },
|
|
456
|
+
data: { status: JobStatus.FAILED },
|
|
457
|
+
select: { id: true, conversationId: true, status: true },
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
logger.info('AI message marked as failed', {
|
|
461
|
+
messageId: updated.id,
|
|
462
|
+
conversationId: updated.conversationId,
|
|
463
|
+
reason: options.reason,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
|
|
468
|
+
id: updated.id,
|
|
469
|
+
conversationId: updated.conversationId,
|
|
470
|
+
status: updated.status,
|
|
471
|
+
});
|
|
472
|
+
} catch (broadcastError) {
|
|
473
|
+
logger.error('Failed to broadcast failed AI message:', {
|
|
474
|
+
error: broadcastError,
|
|
475
|
+
messageId,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
logger.error('Failed to mark AI message as failed:', {
|
|
480
|
+
error,
|
|
481
|
+
messageId,
|
|
482
|
+
reason: options.reason,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
279
487
|
/** Responses API user parts use `type: "input_*"`; chat completions use `text` / `image_url` / etc. */
|
|
280
488
|
function isResponseInputWithInputParts(
|
|
281
489
|
content: OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
|
|
@@ -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();
|