@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
|
@@ -121,8 +121,8 @@ export async function createAnnouncementRecord(
|
|
|
121
121
|
|
|
122
122
|
sendToMultiple({
|
|
123
123
|
receiverIds: classData.students.map((s) => s.id),
|
|
124
|
-
title:
|
|
125
|
-
content: remarks
|
|
124
|
+
title: `New announcement in ${classData.name}`,
|
|
125
|
+
content: `${remarks}\n\n[View announcement](/class/${classId})`,
|
|
126
126
|
}).catch((error) => {
|
|
127
127
|
logger.error("Failed to send announcement notifications:", error);
|
|
128
128
|
});
|
|
@@ -23,9 +23,10 @@ import {
|
|
|
23
23
|
} from "../models/assignment.js";
|
|
24
24
|
import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
|
|
25
25
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
26
|
-
import { gradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
27
26
|
import { sendToMultiple } from "./notification.js";
|
|
27
|
+
import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
|
|
28
28
|
import { logger } from "../utils/logger.js";
|
|
29
|
+
import { RunJobType } from "@prisma/client";
|
|
29
30
|
|
|
30
31
|
export async function assignmentExists(id: string) {
|
|
31
32
|
const assignment = await findAssignmentById(id);
|
|
@@ -312,8 +313,8 @@ export async function createAssignmentRecord(
|
|
|
312
313
|
|
|
313
314
|
sendToMultiple({
|
|
314
315
|
receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
|
|
315
|
-
title: `New assignment
|
|
316
|
-
content: `
|
|
316
|
+
title: `New assignment in ${classData.name}`,
|
|
317
|
+
content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
|
|
317
318
|
}).catch((error) => {
|
|
318
319
|
logger.error("Failed to send assignment notifications:", error);
|
|
319
320
|
});
|
|
@@ -664,9 +665,22 @@ export async function updateSubmissionRecord(
|
|
|
664
665
|
) {
|
|
665
666
|
const worksheetResponses = await prisma.studentWorksheetResponse.findMany({
|
|
666
667
|
where: { submissionId: submission.id },
|
|
668
|
+
select: { id: true },
|
|
667
669
|
});
|
|
668
670
|
for (const wr of worksheetResponses) {
|
|
669
|
-
|
|
671
|
+
try {
|
|
672
|
+
await enqueueRunJob(
|
|
673
|
+
QUEUE_NAMES.GRADE_WORKSHEET as RunJobType,
|
|
674
|
+
{ worksheetResponseId: wr.id },
|
|
675
|
+
{ classId: submission.assignment.classId, userId },
|
|
676
|
+
);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
logger.error("Failed to enqueue worksheet grading on submit", {
|
|
679
|
+
error,
|
|
680
|
+
worksheetResponseId: wr.id,
|
|
681
|
+
submissionId: submission.id,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
670
684
|
}
|
|
671
685
|
}
|
|
672
686
|
return prisma.submission.update({
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
createAttendanceForEvent,
|
|
15
15
|
updateAttendance,
|
|
16
16
|
} from "../models/attendance.js";
|
|
17
|
+
import { sendToMultiple } from "./notification.js";
|
|
18
|
+
import { logger } from "../utils/logger.js";
|
|
17
19
|
|
|
18
20
|
/** Get attendance for a class (optionally filtered by event). Creates missing records. */
|
|
19
21
|
export async function getAttendance(classId: string, eventId?: string) {
|
|
@@ -66,20 +68,103 @@ export async function updateAttendanceRecord(
|
|
|
66
68
|
const presentIds = attendance.present.map((s) => s.id);
|
|
67
69
|
const lateIds = attendance.late.map((s) => s.id);
|
|
68
70
|
const absentIds = attendance.absent.map((s) => s.id);
|
|
71
|
+
const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
|
|
72
|
+
const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);
|
|
73
|
+
const classStudentIds = new Set(classData.students.map((student) => student.id));
|
|
69
74
|
|
|
70
75
|
if (!existing) {
|
|
71
|
-
|
|
76
|
+
const created = await createAttendance({
|
|
72
77
|
classId,
|
|
73
78
|
eventId: eventId ?? null,
|
|
74
79
|
presentIds,
|
|
75
80
|
lateIds,
|
|
76
81
|
absentIds,
|
|
77
82
|
});
|
|
83
|
+
await notifyMarkedLateOrAbsent({
|
|
84
|
+
classId,
|
|
85
|
+
className: classData.name,
|
|
86
|
+
eventId: eventId ?? null,
|
|
87
|
+
eventName: created.event?.name ?? null,
|
|
88
|
+
classStudentIds,
|
|
89
|
+
lateIds,
|
|
90
|
+
absentIds,
|
|
91
|
+
previousLateIds,
|
|
92
|
+
previousAbsentIds,
|
|
93
|
+
});
|
|
94
|
+
return created;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
|
|
97
|
+
const updated = await updateAttendance(existing.id, {
|
|
81
98
|
presentIds,
|
|
82
99
|
lateIds,
|
|
83
100
|
absentIds,
|
|
84
101
|
});
|
|
102
|
+
await notifyMarkedLateOrAbsent({
|
|
103
|
+
classId,
|
|
104
|
+
className: classData.name,
|
|
105
|
+
eventId: eventId ?? null,
|
|
106
|
+
eventName: existing.event?.name ?? null,
|
|
107
|
+
classStudentIds,
|
|
108
|
+
lateIds,
|
|
109
|
+
absentIds,
|
|
110
|
+
previousLateIds,
|
|
111
|
+
previousAbsentIds,
|
|
112
|
+
});
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Notify only students whose late or absent status is newly added. */
|
|
117
|
+
async function notifyMarkedLateOrAbsent(input: {
|
|
118
|
+
classId: string;
|
|
119
|
+
className: string;
|
|
120
|
+
eventId: string | null;
|
|
121
|
+
eventName: string | null;
|
|
122
|
+
classStudentIds: Set<string>;
|
|
123
|
+
lateIds: string[];
|
|
124
|
+
absentIds: string[];
|
|
125
|
+
previousLateIds: Set<string>;
|
|
126
|
+
previousAbsentIds: Set<string>;
|
|
127
|
+
}) {
|
|
128
|
+
const lateReceiverIds = [...new Set(input.lateIds)].filter(
|
|
129
|
+
(id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id)
|
|
130
|
+
);
|
|
131
|
+
const absentReceiverIds = [...new Set(input.absentIds)].filter(
|
|
132
|
+
(id) => input.classStudentIds.has(id) && !input.previousAbsentIds.has(id)
|
|
133
|
+
);
|
|
134
|
+
const link = input.eventId
|
|
135
|
+
? `/class/${input.classId}/attendance/${input.eventId}`
|
|
136
|
+
: `/class/${input.classId}/attendance`;
|
|
137
|
+
const attendanceLabel = input.eventName
|
|
138
|
+
? `"${input.eventName}" in ${input.className}`
|
|
139
|
+
: input.className;
|
|
140
|
+
|
|
141
|
+
await Promise.all([
|
|
142
|
+
sendAttendanceNotification({
|
|
143
|
+
receiverIds: lateReceiverIds,
|
|
144
|
+
title: `Marked late in ${input.className}`,
|
|
145
|
+
content: `You were marked late for ${attendanceLabel}.\n[View attendance](${link})`,
|
|
146
|
+
}),
|
|
147
|
+
sendAttendanceNotification({
|
|
148
|
+
receiverIds: absentReceiverIds,
|
|
149
|
+
title: `Marked absent in ${input.className}`,
|
|
150
|
+
content: `You were marked absent for ${attendanceLabel}.\n[View attendance](${link})`,
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Send attendance notifications without failing the attendance update itself. */
|
|
156
|
+
async function sendAttendanceNotification(input: {
|
|
157
|
+
receiverIds: string[];
|
|
158
|
+
title: string;
|
|
159
|
+
content: string;
|
|
160
|
+
}) {
|
|
161
|
+
if (input.receiverIds.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await sendToMultiple(input);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.error("Failed to send attendance notifications:", { error });
|
|
169
|
+
}
|
|
85
170
|
}
|
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, RunJobType } from "@prisma/client";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { chatChannel, teacherChannel, pusher } from "../lib/pusher.js";
|
|
9
9
|
import {
|
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
findLabChatForDelete,
|
|
18
18
|
findMentionedMemberships,
|
|
19
19
|
} from "../models/labChat.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { getAIUserId, isAIUser } from "../utils/aiUser.js";
|
|
21
|
+
import { createPendingAIMessage } from "../utils/inference.js";
|
|
22
22
|
import { logger } from "../utils/logger.js";
|
|
23
|
+
import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
|
|
23
24
|
import {
|
|
24
25
|
claimMessageAttachments,
|
|
25
26
|
ensureMessageHasContentOrAttachments,
|
|
@@ -125,12 +126,23 @@ export async function createLabChat(
|
|
|
125
126
|
return labChat;
|
|
126
127
|
});
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Create the AI introduction placeholder *outside* the transaction (the
|
|
130
|
+
// helper triggers Pusher and we don't want a tx rollback to leave a
|
|
131
|
+
// dangling broadcast). The pipeline will fill this row in.
|
|
132
|
+
try {
|
|
133
|
+
const aiPlaceholder = await createPendingAIMessage(result.conversationId);
|
|
134
|
+
await enqueueRunJob(
|
|
135
|
+
QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
|
|
136
|
+
{ conversationId: result.conversationId, aiMessageId: aiPlaceholder.id },
|
|
137
|
+
{ classId, userId },
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.error("Failed to enqueue lab chat introduction", {
|
|
141
|
+
error,
|
|
142
|
+
labChatId: result.id,
|
|
143
|
+
conversationId: result.conversationId,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
134
146
|
|
|
135
147
|
try {
|
|
136
148
|
await pusher.trigger(teacherChannel(classId), "lab-chat-created", {
|
|
@@ -172,11 +184,13 @@ export async function getLabChat(userId: string, labChatId: string) {
|
|
|
172
184
|
});
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
// The AI placeholder row IS the pending generation marker now — its
|
|
188
|
+
// status flips PENDING → COMPLETED / FAILED as the pipeline runs.
|
|
175
189
|
const pendingMessage = await prisma.message.findFirst({
|
|
176
190
|
where: {
|
|
177
191
|
conversationId: labChat.conversationId,
|
|
178
|
-
status:
|
|
179
|
-
senderId:
|
|
192
|
+
status: JobStatus.PENDING,
|
|
193
|
+
senderId: getAIUserId(),
|
|
180
194
|
},
|
|
181
195
|
orderBy: { createdAt: "desc" },
|
|
182
196
|
select: { id: true },
|
|
@@ -252,7 +266,6 @@ export async function postToLabChat(
|
|
|
252
266
|
content: normalizedContent,
|
|
253
267
|
senderId: userId,
|
|
254
268
|
conversationId: labChat.conversationId,
|
|
255
|
-
...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
|
|
256
269
|
},
|
|
257
270
|
});
|
|
258
271
|
|
|
@@ -309,6 +322,7 @@ export async function postToLabChat(
|
|
|
309
322
|
content: result.content,
|
|
310
323
|
senderId: result.senderId,
|
|
311
324
|
conversationId: result.conversationId,
|
|
325
|
+
status: result.status,
|
|
312
326
|
createdAt: result.createdAt,
|
|
313
327
|
sender: result.sender,
|
|
314
328
|
attachments: result.attachments ?? [],
|
|
@@ -321,26 +335,49 @@ export async function postToLabChat(
|
|
|
321
335
|
}
|
|
322
336
|
|
|
323
337
|
if (!isAIUser(userId)) {
|
|
338
|
+
// Create the AI placeholder post-tx so a transaction rollback can never
|
|
339
|
+
// leave a dangling PENDING bubble. The placeholder broadcast IS the
|
|
340
|
+
// "AI is thinking..." indicator inline in the chat; the teacher-channel
|
|
341
|
+
// event below drives the lab list "AI busy in this lab" pill.
|
|
342
|
+
let aiPlaceholderId: string | null = null;
|
|
343
|
+
try {
|
|
344
|
+
const aiPlaceholder = await createPendingAIMessage(labChat.conversationId);
|
|
345
|
+
aiPlaceholderId = aiPlaceholder.id;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger.error("Failed to create AI placeholder for lab response", {
|
|
348
|
+
error,
|
|
349
|
+
labChatId,
|
|
350
|
+
teacherMessageId: result.id,
|
|
351
|
+
conversationId: labChat.conversationId,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
324
355
|
try {
|
|
325
356
|
await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
|
|
326
357
|
labChatId,
|
|
327
|
-
messageId: result.id,
|
|
358
|
+
messageId: aiPlaceholderId ?? result.id,
|
|
328
359
|
conversationId: labChat.conversationId,
|
|
329
360
|
});
|
|
330
361
|
} catch (error) {
|
|
331
362
|
console.error("Failed to broadcast lab response pending:", error);
|
|
332
363
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
364
|
+
|
|
365
|
+
if (aiPlaceholderId) {
|
|
366
|
+
try {
|
|
367
|
+
await enqueueRunJob(
|
|
368
|
+
QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
|
|
369
|
+
{ conversationId: labChat.conversationId, aiMessageId: aiPlaceholderId },
|
|
370
|
+
{ classId: labChat.classId, userId },
|
|
371
|
+
);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
logger.error("Failed to enqueue lab response", {
|
|
374
|
+
error,
|
|
375
|
+
labChatId,
|
|
376
|
+
aiMessageId: aiPlaceholderId,
|
|
377
|
+
conversationId: labChat.conversationId,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
344
381
|
}
|
|
345
382
|
|
|
346
383
|
return {
|
|
@@ -348,6 +385,7 @@ export async function postToLabChat(
|
|
|
348
385
|
content: result.content,
|
|
349
386
|
senderId: result.senderId,
|
|
350
387
|
conversationId: result.conversationId,
|
|
388
|
+
status: result.status,
|
|
351
389
|
createdAt: result.createdAt,
|
|
352
390
|
sender: result.sender,
|
|
353
391
|
attachments: result.attachments ?? [],
|
|
@@ -406,7 +444,13 @@ export async function deleteLabChat(userId: string, labChatId: string) {
|
|
|
406
444
|
return { success: true };
|
|
407
445
|
}
|
|
408
446
|
|
|
409
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Rerun the last AI response in a lab chat.
|
|
449
|
+
*
|
|
450
|
+
* The AI message bubble is reused (not deleted): we flip it back to
|
|
451
|
+
* PENDING and clear its content so the existing message id stays stable
|
|
452
|
+
* for the client. The pipeline then refills it.
|
|
453
|
+
*/
|
|
410
454
|
export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
411
455
|
const labChat = await findLabChatForPost(labChatId, userId);
|
|
412
456
|
if (!labChat) {
|
|
@@ -446,46 +490,62 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
|
446
490
|
});
|
|
447
491
|
}
|
|
448
492
|
|
|
449
|
-
await prisma
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
493
|
+
const reusedAiMessage = await prisma.message.update({
|
|
494
|
+
where: { id: lastMessage.id },
|
|
495
|
+
data: {
|
|
496
|
+
content: "",
|
|
497
|
+
status: JobStatus.PENDING,
|
|
498
|
+
meta: {},
|
|
499
|
+
},
|
|
500
|
+
select: { id: true, conversationId: true, status: true, content: true, senderId: true, createdAt: true },
|
|
501
|
+
});
|
|
458
502
|
|
|
503
|
+
// Tell the chat UI the existing AI bubble is regenerating — content
|
|
504
|
+
// cleared, status flipped back to PENDING, same id.
|
|
459
505
|
try {
|
|
460
|
-
await pusher.trigger(chatChannel(labChat.conversationId), "message-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
senderId:
|
|
506
|
+
await pusher.trigger(chatChannel(labChat.conversationId), "message-updated", {
|
|
507
|
+
id: reusedAiMessage.id,
|
|
508
|
+
content: reusedAiMessage.content,
|
|
509
|
+
senderId: reusedAiMessage.senderId,
|
|
510
|
+
conversationId: reusedAiMessage.conversationId,
|
|
511
|
+
status: reusedAiMessage.status,
|
|
512
|
+
createdAt: reusedAiMessage.createdAt,
|
|
513
|
+
sender: {
|
|
514
|
+
id: reusedAiMessage.senderId,
|
|
515
|
+
username: 'Newton_AI',
|
|
516
|
+
profile: { displayName: 'Newton AI', profilePicture: null },
|
|
517
|
+
},
|
|
518
|
+
mentionedUserIds: [],
|
|
519
|
+
meta: {},
|
|
520
|
+
attachments: [],
|
|
464
521
|
});
|
|
465
522
|
} catch (error) {
|
|
466
|
-
console.error("Failed to broadcast message
|
|
523
|
+
console.error("Failed to broadcast AI message rerun reset:", error);
|
|
467
524
|
}
|
|
468
525
|
try {
|
|
469
526
|
await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
|
|
470
527
|
labChatId,
|
|
471
|
-
messageId:
|
|
528
|
+
messageId: reusedAiMessage.id,
|
|
472
529
|
conversationId: labChat.conversationId,
|
|
473
530
|
});
|
|
474
531
|
} catch (error) {
|
|
475
532
|
console.error("Failed to broadcast lab response pending:", error);
|
|
476
533
|
}
|
|
477
534
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
535
|
+
try {
|
|
536
|
+
await enqueueRunJob(
|
|
537
|
+
QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
|
|
538
|
+
{ conversationId: labChat.conversationId, aiMessageId: reusedAiMessage.id },
|
|
539
|
+
{ classId: labChat.classId, userId },
|
|
540
|
+
);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
logger.error("Failed to enqueue lab response rerun", {
|
|
483
543
|
error,
|
|
484
544
|
labChatId,
|
|
485
|
-
|
|
545
|
+
aiMessageId: reusedAiMessage.id,
|
|
486
546
|
conversationId: labChat.conversationId,
|
|
487
|
-
})
|
|
488
|
-
|
|
547
|
+
});
|
|
548
|
+
}
|
|
489
549
|
|
|
490
550
|
return { success: true };
|
|
491
551
|
}
|
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,
|
|
@@ -179,6 +192,7 @@ export async function listMessages(
|
|
|
179
192
|
conversationId: message.conversationId,
|
|
180
193
|
createdAt: message.createdAt,
|
|
181
194
|
sender: message.sender,
|
|
195
|
+
status: message.status,
|
|
182
196
|
attachments: message.attachments.map((a) => ({
|
|
183
197
|
id: a.id,
|
|
184
198
|
name: a.name,
|
|
@@ -303,6 +317,7 @@ export async function sendMessage(
|
|
|
303
317
|
content: result.content,
|
|
304
318
|
senderId: result.senderId,
|
|
305
319
|
conversationId: result.conversationId,
|
|
320
|
+
status: result.status,
|
|
306
321
|
createdAt: result.createdAt,
|
|
307
322
|
sender: result.sender,
|
|
308
323
|
attachments: result.attachments ?? [],
|
|
@@ -323,6 +338,7 @@ export async function sendMessage(
|
|
|
323
338
|
content: result.content,
|
|
324
339
|
senderId: result.senderId,
|
|
325
340
|
conversationId: result.conversationId,
|
|
341
|
+
status: result.status,
|
|
326
342
|
createdAt: result.createdAt,
|
|
327
343
|
sender: result.sender,
|
|
328
344
|
attachments: result.attachments ?? [],
|
|
@@ -427,6 +443,7 @@ export async function updateMessage(
|
|
|
427
443
|
content: updatedMessage.content,
|
|
428
444
|
senderId: updatedMessage.senderId,
|
|
429
445
|
conversationId: updatedMessage.conversationId,
|
|
446
|
+
status: updatedMessage.status,
|
|
430
447
|
createdAt: updatedMessage.createdAt,
|
|
431
448
|
sender: updatedMessage.sender,
|
|
432
449
|
attachments: updatedMessage.attachments ?? [],
|
|
@@ -447,6 +464,7 @@ export async function updateMessage(
|
|
|
447
464
|
content: updatedMessage.content,
|
|
448
465
|
senderId: updatedMessage.senderId,
|
|
449
466
|
conversationId: updatedMessage.conversationId,
|
|
467
|
+
status: updatedMessage.status,
|
|
450
468
|
createdAt: updatedMessage.createdAt,
|
|
451
469
|
sender: updatedMessage.sender,
|
|
452
470
|
mentionedUserIds,
|
|
@@ -620,6 +638,7 @@ export async function markSuggestionCreated(
|
|
|
620
638
|
content: updated.content,
|
|
621
639
|
senderId: updated.senderId,
|
|
622
640
|
conversationId: updated.conversationId,
|
|
641
|
+
status: updated.status,
|
|
623
642
|
createdAt: updated.createdAt,
|
|
624
643
|
sender: updated.sender,
|
|
625
644
|
attachments: updated.attachments ?? [],
|
|
@@ -6,7 +6,8 @@ import { TRPCError } from "@trpc/server";
|
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
8
8
|
import { isAIUser } from "../utils/aiUser.js";
|
|
9
|
-
import {
|
|
9
|
+
import { createPendingAIMessage } from "../utils/inference.js";
|
|
10
|
+
import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
|
|
10
11
|
import {
|
|
11
12
|
claimMessageAttachments,
|
|
12
13
|
ensureMessageHasContentOrAttachments,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
findConversationMembersByUserIds,
|
|
19
20
|
} from "../models/newtonChat.js";
|
|
20
21
|
import { prisma } from "../lib/prisma.js";
|
|
22
|
+
import { RunJobType } from "@prisma/client";
|
|
21
23
|
|
|
22
24
|
/** Get or create Newton tutor conversation for a submission. Student-only. */
|
|
23
25
|
export async function getTutorConversation(
|
|
@@ -44,7 +46,7 @@ export async function getTutorConversation(
|
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
const
|
|
49
|
+
const { chat, isNew } = await prisma.$transaction(async (tx) => {
|
|
48
50
|
const existingNewtonChat = await tx.newtonChat.findFirst({
|
|
49
51
|
where: { submissionId: submission.id },
|
|
50
52
|
include: {
|
|
@@ -60,7 +62,7 @@ export async function getTutorConversation(
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
if (existingNewtonChat && existingNewtonChat.conversation.members.length > 0) {
|
|
63
|
-
return existingNewtonChat;
|
|
65
|
+
return { chat: existingNewtonChat, isNew: false };
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
if (existingNewtonChat) {
|
|
@@ -71,7 +73,7 @@ export async function getTutorConversation(
|
|
|
71
73
|
role: "MEMBER",
|
|
72
74
|
},
|
|
73
75
|
});
|
|
74
|
-
return existingNewtonChat;
|
|
76
|
+
return { chat: existingNewtonChat, isNew: false };
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
const conversation = await tx.conversation.create({
|
|
@@ -98,23 +100,31 @@ export async function getTutorConversation(
|
|
|
98
100
|
},
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
return { chat: newtonChat, isNew: true };
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (isNew) {
|
|
107
|
+
// Create the AI introduction placeholder and enqueue OUTSIDE the
|
|
108
|
+
// transaction — a tx rollback would otherwise orphan a job pointing at
|
|
109
|
+
// a non-existent NewtonChat / Message row.
|
|
110
|
+
try {
|
|
111
|
+
const aiPlaceholder = await createPendingAIMessage(chat.conversationId);
|
|
112
|
+
await enqueueRunJob(
|
|
113
|
+
QUEUE_NAMES.AI_NEWTON_CHAT as RunJobType,
|
|
114
|
+
{ conversationId: chat.conversationId, aiMessageId: aiPlaceholder.id },
|
|
115
|
+
{ userId },
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.error("Failed to enqueue Newton introduction", {
|
|
107
119
|
error,
|
|
108
|
-
newtonChatId:
|
|
120
|
+
newtonChatId: chat.id,
|
|
109
121
|
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return newtonChat;
|
|
113
|
-
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
114
124
|
|
|
115
125
|
return {
|
|
116
|
-
conversationId:
|
|
117
|
-
newtonChatId:
|
|
126
|
+
conversationId: chat.conversationId,
|
|
127
|
+
newtonChatId: chat.id,
|
|
118
128
|
};
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -218,6 +228,7 @@ export async function postToNewtonChat(
|
|
|
218
228
|
content: result.content,
|
|
219
229
|
senderId: result.senderId,
|
|
220
230
|
conversationId: result.conversationId,
|
|
231
|
+
status: result.status,
|
|
221
232
|
createdAt: result.createdAt,
|
|
222
233
|
sender: result.sender,
|
|
223
234
|
attachments: result.attachments ?? [],
|
|
@@ -230,14 +241,23 @@ export async function postToNewtonChat(
|
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
if (!isAIUser(userId)) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
newtonChat.conversationId
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
244
|
+
// Create the AI placeholder post-tx (so a rollback can't broadcast a
|
|
245
|
+
// ghost bubble); the pipeline fills it in.
|
|
246
|
+
try {
|
|
247
|
+
const aiPlaceholder = await createPendingAIMessage(newtonChat.conversationId);
|
|
248
|
+
await enqueueRunJob(
|
|
249
|
+
QUEUE_NAMES.AI_NEWTON_CHAT as RunJobType,
|
|
250
|
+
{ conversationId: newtonChat.conversationId, aiMessageId: aiPlaceholder.id },
|
|
251
|
+
{ userId },
|
|
252
|
+
);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.error("Failed to enqueue Newton response", {
|
|
255
|
+
error,
|
|
256
|
+
newtonChatId,
|
|
257
|
+
studentMessageId: result.id,
|
|
258
|
+
conversationId: newtonChat.conversationId,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
return {
|
|
@@ -245,6 +265,7 @@ export async function postToNewtonChat(
|
|
|
245
265
|
content: result.content,
|
|
246
266
|
senderId: result.senderId,
|
|
247
267
|
conversationId: result.conversationId,
|
|
268
|
+
status: result.status,
|
|
248
269
|
createdAt: result.createdAt,
|
|
249
270
|
sender: result.sender,
|
|
250
271
|
attachments: result.attachments ?? [],
|