@studious-lms/server 1.26.0 → 1.28.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/.env.example +3 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/instrument.js +9 -4
- package/dist/instrument.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +15 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +43 -2
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/generateVisualization.d.ts +33 -0
- package/dist/pipelines/generateVisualization.d.ts.map +1 -0
- package/dist/pipelines/generateVisualization.js +79 -0
- package/dist/pipelines/generateVisualization.js.map +1 -0
- package/dist/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1118 -70
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +297 -1
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +43 -2
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +16 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/visualization.d.ts +124 -0
- package/dist/routers/visualization.d.ts.map +1 -0
- package/dist/routers/visualization.js +41 -0
- package/dist/routers/visualization.js.map +1 -0
- package/dist/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminClass.d.ts +153 -0
- package/dist/services/adminClass.d.ts.map +1 -0
- package/dist/services/adminClass.js +196 -0
- package/dist/services/adminClass.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +11 -4
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +8 -0
- package/dist/services/adminMetrics.d.ts.map +1 -1
- package/dist/services/adminMetrics.js +30 -3
- package/dist/services/adminMetrics.js.map +1 -1
- package/dist/services/adminSpam.d.ts +76 -0
- package/dist/services/adminSpam.d.ts.map +1 -0
- package/dist/services/adminSpam.js +163 -0
- package/dist/services/adminSpam.js.map +1 -0
- package/dist/services/adminSql.d.ts +36 -0
- package/dist/services/adminSql.d.ts.map +1 -0
- package/dist/services/adminSql.js +172 -0
- package/dist/services/adminSql.js.map +1 -0
- package/dist/services/adminTelemetry.d.ts +56 -0
- package/dist/services/adminTelemetry.d.ts.map +1 -0
- package/dist/services/adminTelemetry.js +147 -0
- package/dist/services/adminTelemetry.js.map +1 -0
- package/dist/services/adminUser.d.ts +45 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +53 -13
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +10 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +7 -3
- package/dist/services/fees.js.map +1 -1
- package/dist/services/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.js.map +1 -1
- package/dist/services/module.d.ts.map +1 -1
- package/dist/services/module.js +23 -4
- package/dist/services/module.js.map +1 -1
- package/dist/services/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/visualization.d.ts +72 -0
- package/dist/services/visualization.d.ts.map +1 -0
- package/dist/services/visualization.js +122 -0
- package/dist/services/visualization.js.map +1 -0
- package/dist/utils/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/utils/sentryTrpc.d.ts +3 -0
- package/dist/utils/sentryTrpc.d.ts.map +1 -0
- package/dist/utils/sentryTrpc.js +26 -0
- package/dist/utils/sentryTrpc.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +32 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/labChat.ts +5 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routers/visualization.ts +60 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- package/src/services/adminClass.ts +222 -0
- package/src/services/adminInstitution.ts +9 -2
- package/src/services/adminMetrics.ts +35 -0
- package/src/services/adminSpam.ts +224 -0
- package/src/services/adminSql.ts +214 -0
- package/src/services/adminTelemetry.ts +194 -0
- package/src/services/adminUser.ts +66 -11
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +29 -2
- package/src/services/parentPortal.ts +85 -0
- package/src/services/studentProgress.ts +135 -0
- package/src/services/visualization.ts +147 -0
- package/src/utils/inference.ts +85 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- package/tests/unit/lib/fileUploadSession.test.ts +67 -0
- package/tests/unit/services/adminClass.test.ts +102 -0
- package/tests/unit/services/adminSpam.test.ts +108 -0
- package/tests/unit/services/adminSql.test.ts +56 -0
- package/tests/unit/services/adminTelemetry.test.ts +47 -0
- package/tests/unit/services/adminUser.test.ts +77 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
- package/tests/unit/utils/sentryTrpc.test.ts +15 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { prisma } from "../lib/prisma.js";
|
|
3
|
+
import { isTeacherInClass } from "../models/class.js";
|
|
4
|
+
import {
|
|
5
|
+
generateVisualization,
|
|
6
|
+
MAX_VISUALIZATION_HTML_LENGTH,
|
|
7
|
+
reviseVisualization,
|
|
8
|
+
} from "../pipelines/generateVisualization.js";
|
|
9
|
+
|
|
10
|
+
const summarySelect = {
|
|
11
|
+
id: true,
|
|
12
|
+
classId: true,
|
|
13
|
+
title: true,
|
|
14
|
+
prompt: true,
|
|
15
|
+
published: true,
|
|
16
|
+
createdAt: true,
|
|
17
|
+
updatedAt: true,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const detailSelect = { ...summarySelect, html: true } as const;
|
|
21
|
+
|
|
22
|
+
async function getClassContext(classId: string) {
|
|
23
|
+
return prisma.class.findUnique({
|
|
24
|
+
where: { id: classId },
|
|
25
|
+
select: { name: true, subject: true, schoolId: true },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function findTeacherVisualization(classId: string, id: string) {
|
|
30
|
+
const viz = await prisma.visualization.findFirst({
|
|
31
|
+
where: { id, classId },
|
|
32
|
+
select: detailSelect,
|
|
33
|
+
});
|
|
34
|
+
if (!viz) {
|
|
35
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Visualisation not found" });
|
|
36
|
+
}
|
|
37
|
+
return viz;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function aiError(error: unknown): never {
|
|
41
|
+
throw new TRPCError({
|
|
42
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
43
|
+
message: "Newton couldn't build that visualisation. Try rephrasing it.",
|
|
44
|
+
cause: error,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Teachers see every visualisation in the class; students only published ones. */
|
|
49
|
+
export async function listVisualizations(userId: string, classId: string) {
|
|
50
|
+
const isTeacher = !!(await isTeacherInClass(classId, userId));
|
|
51
|
+
return prisma.visualization.findMany({
|
|
52
|
+
where: { classId, ...(isTeacher ? {} : { published: true }) },
|
|
53
|
+
select: summarySelect,
|
|
54
|
+
orderBy: { updatedAt: "desc" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getVisualization(userId: string, classId: string, id: string) {
|
|
59
|
+
const isTeacher = !!(await isTeacherInClass(classId, userId));
|
|
60
|
+
const viz = await prisma.visualization.findFirst({
|
|
61
|
+
where: { id, classId, ...(isTeacher ? {} : { published: true }) },
|
|
62
|
+
select: detailSelect,
|
|
63
|
+
});
|
|
64
|
+
if (!viz) {
|
|
65
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Visualisation not found" });
|
|
66
|
+
}
|
|
67
|
+
return viz;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function createVisualization(
|
|
71
|
+
userId: string,
|
|
72
|
+
input: { classId: string; prompt: string; title?: string },
|
|
73
|
+
) {
|
|
74
|
+
const cls = await getClassContext(input.classId);
|
|
75
|
+
const generated = await generateVisualization(input.prompt, {
|
|
76
|
+
className: cls?.name,
|
|
77
|
+
subject: cls?.subject,
|
|
78
|
+
attribution: { userId, classId: input.classId, schoolId: cls?.schoolId },
|
|
79
|
+
}).catch(aiError);
|
|
80
|
+
|
|
81
|
+
return prisma.visualization.create({
|
|
82
|
+
data: {
|
|
83
|
+
classId: input.classId,
|
|
84
|
+
createdById: userId,
|
|
85
|
+
title: input.title?.trim() || generated.title,
|
|
86
|
+
prompt: input.prompt,
|
|
87
|
+
html: generated.html,
|
|
88
|
+
},
|
|
89
|
+
select: detailSelect,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function reviseVisualizationRecord(
|
|
94
|
+
userId: string,
|
|
95
|
+
input: { classId: string; id: string; instruction: string },
|
|
96
|
+
) {
|
|
97
|
+
const current = await findTeacherVisualization(input.classId, input.id);
|
|
98
|
+
const cls = await getClassContext(input.classId);
|
|
99
|
+
const revised = await reviseVisualization(current, input.instruction, {
|
|
100
|
+
className: cls?.name,
|
|
101
|
+
subject: cls?.subject,
|
|
102
|
+
attribution: { userId, classId: input.classId, schoolId: cls?.schoolId },
|
|
103
|
+
}).catch(aiError);
|
|
104
|
+
|
|
105
|
+
return prisma.visualization.update({
|
|
106
|
+
where: { id: current.id },
|
|
107
|
+
data: {
|
|
108
|
+
html: revised.html,
|
|
109
|
+
prompt: [current.prompt, input.instruction].filter(Boolean).join("\n\n"),
|
|
110
|
+
},
|
|
111
|
+
select: detailSelect,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function updateVisualization(input: {
|
|
116
|
+
classId: string;
|
|
117
|
+
id: string;
|
|
118
|
+
title?: string;
|
|
119
|
+
html?: string;
|
|
120
|
+
published?: boolean;
|
|
121
|
+
}) {
|
|
122
|
+
const current = await findTeacherVisualization(input.classId, input.id);
|
|
123
|
+
if (input.html !== undefined && input.html.length > MAX_VISUALIZATION_HTML_LENGTH) {
|
|
124
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Visualisation is too large" });
|
|
125
|
+
}
|
|
126
|
+
return prisma.visualization.update({
|
|
127
|
+
where: { id: current.id },
|
|
128
|
+
data: {
|
|
129
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
130
|
+
...(input.html !== undefined ? { html: input.html } : {}),
|
|
131
|
+
...(input.published !== undefined ? { published: input.published } : {}),
|
|
132
|
+
},
|
|
133
|
+
select: detailSelect,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Deletes the visualisation and any module items that pointed at it. */
|
|
138
|
+
export async function deleteVisualization(input: { classId: string; id: string }) {
|
|
139
|
+
const current = await findTeacherVisualization(input.classId, input.id);
|
|
140
|
+
await prisma.$transaction([
|
|
141
|
+
prisma.courseModuleItem.deleteMany({
|
|
142
|
+
where: { itemType: "VISUALIZATION", itemId: current.id },
|
|
143
|
+
}),
|
|
144
|
+
prisma.visualization.delete({ where: { id: current.id } }),
|
|
145
|
+
]);
|
|
146
|
+
return { id: current.id };
|
|
147
|
+
}
|
package/src/utils/inference.ts
CHANGED
|
@@ -476,6 +476,77 @@ export async function completePendingAIMessage(
|
|
|
476
476
|
};
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Persist the partial output of a cancelled generation and flip the AI
|
|
481
|
+
* message to CANCELLED. Whatever text/tool parts streamed before the stop
|
|
482
|
+
* are kept on the row (content + meta) so they survive reloads. Broadcasts
|
|
483
|
+
* `message-updated` so clients patch the bubble in place.
|
|
484
|
+
*/
|
|
485
|
+
export async function cancelPendingAIMessage(
|
|
486
|
+
messageId: string,
|
|
487
|
+
content: string,
|
|
488
|
+
options: {
|
|
489
|
+
meta?: Record<string, any>;
|
|
490
|
+
customSender?: AIMessageSenderInfo;
|
|
491
|
+
} = {},
|
|
492
|
+
): Promise<void> {
|
|
493
|
+
try {
|
|
494
|
+
const updated = await prisma.message.update({
|
|
495
|
+
where: { id: messageId },
|
|
496
|
+
data: {
|
|
497
|
+
content,
|
|
498
|
+
status: JobStatus.CANCELLED,
|
|
499
|
+
...(options.meta !== undefined && {
|
|
500
|
+
meta: options.meta as Prisma.InputJsonValue,
|
|
501
|
+
}),
|
|
502
|
+
},
|
|
503
|
+
include: {
|
|
504
|
+
attachments: true,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
logger.info('AI message cancelled', {
|
|
509
|
+
messageId: updated.id,
|
|
510
|
+
conversationId: updated.conversationId,
|
|
511
|
+
contentLength: content.length,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const senderInfo = buildAISenderInfo(options.customSender);
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
|
|
518
|
+
id: updated.id,
|
|
519
|
+
content: updated.content,
|
|
520
|
+
senderId: updated.senderId,
|
|
521
|
+
conversationId: updated.conversationId,
|
|
522
|
+
status: updated.status,
|
|
523
|
+
createdAt: updated.createdAt,
|
|
524
|
+
sender: senderInfo,
|
|
525
|
+
mentionedUserIds: [],
|
|
526
|
+
meta: updated.meta ?? {},
|
|
527
|
+
attachments: updated.attachments.map((attachment) => ({
|
|
528
|
+
id: attachment.id,
|
|
529
|
+
attachmentId: attachment.id,
|
|
530
|
+
name: attachment.name,
|
|
531
|
+
type: attachment.type,
|
|
532
|
+
size: attachment.size,
|
|
533
|
+
path: attachment.path,
|
|
534
|
+
})),
|
|
535
|
+
});
|
|
536
|
+
} catch (broadcastError) {
|
|
537
|
+
logger.error('Failed to broadcast cancelled AI message:', {
|
|
538
|
+
error: broadcastError,
|
|
539
|
+
messageId,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
logger.error('Failed to mark AI message as cancelled:', {
|
|
544
|
+
error,
|
|
545
|
+
messageId,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
479
550
|
/**
|
|
480
551
|
* Flip a PENDING AI message to FAILED and broadcast the status change.
|
|
481
552
|
* Does NOT throw — failure paths in the pipelines call this in catch blocks
|
|
@@ -486,6 +557,20 @@ export async function failPendingAIMessage(
|
|
|
486
557
|
options: { reason?: string } = {},
|
|
487
558
|
): Promise<void> {
|
|
488
559
|
try {
|
|
560
|
+
const current = await prisma.message.findUnique({
|
|
561
|
+
where: { id: messageId },
|
|
562
|
+
select: { status: true },
|
|
563
|
+
});
|
|
564
|
+
// A message the user already cancelled must stay CANCELLED — late
|
|
565
|
+
// failures from the aborted pipeline are a consequence of the cancel.
|
|
566
|
+
if (current?.status === JobStatus.CANCELLED) {
|
|
567
|
+
logger.info('Skipping FAILED flip; AI message already cancelled', {
|
|
568
|
+
messageId,
|
|
569
|
+
reason: options.reason,
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
489
574
|
const updated = await prisma.message.update({
|
|
490
575
|
where: { id: messageId },
|
|
491
576
|
data: { status: JobStatus.FAILED },
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TRPCError } from '@trpc/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error codes that mean "the caller did something wrong" — noise in Sentry.
|
|
5
|
+
* Everything else (server faults, unhandled Prisma/AI failures) is reported.
|
|
6
|
+
*/
|
|
7
|
+
const EXPECTED_CODES = new Set([
|
|
8
|
+
'BAD_REQUEST',
|
|
9
|
+
'PARSE_ERROR',
|
|
10
|
+
'UNAUTHORIZED',
|
|
11
|
+
'FORBIDDEN',
|
|
12
|
+
'NOT_FOUND',
|
|
13
|
+
'CONFLICT',
|
|
14
|
+
'PRECONDITION_FAILED',
|
|
15
|
+
'PAYLOAD_TOO_LARGE',
|
|
16
|
+
'UNPROCESSABLE_CONTENT',
|
|
17
|
+
'TOO_MANY_REQUESTS',
|
|
18
|
+
'CLIENT_CLOSED_REQUEST',
|
|
19
|
+
'METHOD_NOT_SUPPORTED',
|
|
20
|
+
'UNSUPPORTED_MEDIA_TYPE',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function isReportableTrpcError(error: Pick<TRPCError, 'code'>): boolean {
|
|
24
|
+
return !EXPECTED_CODES.has(error.code);
|
|
25
|
+
}
|
|
@@ -91,6 +91,14 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
91
91
|
return existing.result;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
if (existing.status === 'CANCELLED') {
|
|
95
|
+
logger.info(`${name} RunJob cancelled before start; skipping`, {
|
|
96
|
+
runJobId,
|
|
97
|
+
bullJobId: job.id,
|
|
98
|
+
});
|
|
99
|
+
return existing.result;
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
await prisma.runJob.update({
|
|
95
103
|
where: { id: runJobId },
|
|
96
104
|
data: {
|
|
@@ -108,8 +116,10 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
108
116
|
classId: existing.classId ?? null,
|
|
109
117
|
});
|
|
110
118
|
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
// `updateMany` with a status guard so a cancellation that landed while
|
|
120
|
+
// the run was in flight is not overwritten with SUCCEEDED.
|
|
121
|
+
await prisma.runJob.updateMany({
|
|
122
|
+
where: { id: runJobId, status: { not: 'CANCELLED' } },
|
|
113
123
|
data: {
|
|
114
124
|
status: 'SUCCEEDED',
|
|
115
125
|
finishedAt: new Date(),
|
|
@@ -146,29 +156,19 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
146
156
|
|
|
147
157
|
if (!runJobId) return;
|
|
148
158
|
|
|
159
|
+
// Status guard: don't let a late failure (e.g. from an aborted stream)
|
|
160
|
+
// overwrite a RunJob the user already cancelled.
|
|
149
161
|
await prisma.runJob
|
|
150
|
-
.
|
|
151
|
-
where: { id: runJobId },
|
|
162
|
+
.updateMany({
|
|
163
|
+
where: { id: runJobId, status: { not: 'CANCELLED' } },
|
|
152
164
|
data: {
|
|
153
165
|
error: err.message,
|
|
154
166
|
...(isFinalAttempt
|
|
155
|
-
? { status: 'FAILED', finishedAt: new Date() }
|
|
167
|
+
? { status: 'FAILED' as const, finishedAt: new Date() }
|
|
156
168
|
: {}),
|
|
157
169
|
},
|
|
158
170
|
})
|
|
159
171
|
.catch((updateError) => {
|
|
160
|
-
// P2025 = record not found. The RunJob row is gone (orphaned job from a
|
|
161
|
-
// shared/reset Redis), so there's nothing to flag as FAILED — log
|
|
162
|
-
// quietly instead of as an error to avoid noise.
|
|
163
|
-
if (
|
|
164
|
-
updateError instanceof Prisma.PrismaClientKnownRequestError &&
|
|
165
|
-
updateError.code === 'P2025'
|
|
166
|
-
) {
|
|
167
|
-
logger.warn(`${name} failure not recorded; RunJob no longer exists`, {
|
|
168
|
-
runJobId,
|
|
169
|
-
});
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
172
|
logger.error(`Failed to record ${name} failure on RunJob`, {
|
|
173
173
|
runJobId,
|
|
174
174
|
error:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
|
|
4
|
+
const prismaMocks = vi.hoisted(() => ({
|
|
5
|
+
file: { findFirst: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/lib/prisma.js', () => ({ prisma: prismaMocks }));
|
|
9
|
+
// GCS client construction touches credentials at import — stub it out.
|
|
10
|
+
vi.mock('../../../src/lib/googleCloudStorage.js', () => ({
|
|
11
|
+
getSignedUrl: vi.fn(),
|
|
12
|
+
objectExists: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
// env.ts validates process.env at import; the function under test never reads
|
|
15
|
+
// env, so a bare stub keeps the import graph from exiting the process.
|
|
16
|
+
vi.mock('../../../src/lib/config/env.js', () => ({ env: {} }));
|
|
17
|
+
|
|
18
|
+
import { validateDirectUploadSession } from '../../../src/lib/fileUpload.js';
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('validateDirectUploadSession', () => {
|
|
25
|
+
test('rejects when no upload session id is supplied', async () => {
|
|
26
|
+
await expect(
|
|
27
|
+
validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: undefined }),
|
|
28
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' } satisfies Partial<TRPCError>);
|
|
29
|
+
expect(prismaMocks.file.findFirst).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects when no pending record matches the path + session', async () => {
|
|
33
|
+
prismaMocks.file.findFirst.mockResolvedValue(null);
|
|
34
|
+
await expect(
|
|
35
|
+
validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
|
|
36
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' } satisfies Partial<TRPCError>);
|
|
37
|
+
|
|
38
|
+
// Capability is keyed on path + session + unexpired + not orphaned.
|
|
39
|
+
const where = prismaMocks.file.findFirst.mock.calls[0][0].where;
|
|
40
|
+
expect(where).toMatchObject({
|
|
41
|
+
path: 'a/b.png',
|
|
42
|
+
uploadSessionId: 'sess-1',
|
|
43
|
+
isOrphaned: false,
|
|
44
|
+
});
|
|
45
|
+
expect(where.uploadExpiresAt.gt).toBeInstanceOf(Date);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('rejects a record that is no longer accepting data', async () => {
|
|
49
|
+
prismaMocks.file.findFirst.mockResolvedValue({
|
|
50
|
+
id: 'f1',
|
|
51
|
+
uploadStatus: 'COMPLETED',
|
|
52
|
+
});
|
|
53
|
+
await expect(
|
|
54
|
+
validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
|
|
55
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' } satisfies Partial<TRPCError>);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('accepts a pending record for the exact path + session', async () => {
|
|
59
|
+
prismaMocks.file.findFirst.mockResolvedValue({
|
|
60
|
+
id: 'f1',
|
|
61
|
+
uploadStatus: 'PENDING',
|
|
62
|
+
});
|
|
63
|
+
await expect(
|
|
64
|
+
validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
|
|
65
|
+
).resolves.toMatchObject({ id: 'f1' });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const prismaMocks = vi.hoisted(() => ({
|
|
4
|
+
class: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn() },
|
|
5
|
+
submission: { groupBy: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
9
|
+
prisma: prismaMocks,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getClassForAdmin,
|
|
14
|
+
listClassesForAdmin,
|
|
15
|
+
} from '../../../src/services/adminClass.js';
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('listClassesForAdmin', () => {
|
|
22
|
+
test('filters empty classes and exposes the last assignment timestamp', async () => {
|
|
23
|
+
const createdAt = new Date('2026-09-01T00:00:00Z');
|
|
24
|
+
prismaMocks.class.findMany.mockResolvedValue([
|
|
25
|
+
{ id: 'c1', name: 'Bio', assignments: [{ createdAt }] },
|
|
26
|
+
{ id: 'c2', name: 'Art', assignments: [] },
|
|
27
|
+
]);
|
|
28
|
+
prismaMocks.class.count.mockResolvedValue(2);
|
|
29
|
+
|
|
30
|
+
const result = await listClassesForAdmin({
|
|
31
|
+
page: 0,
|
|
32
|
+
pageSize: 25,
|
|
33
|
+
membership: 'empty',
|
|
34
|
+
sort: 'students',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(prismaMocks.class.findMany).toHaveBeenCalledWith(
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
where: { AND: [{ students: { none: {} } }] },
|
|
40
|
+
orderBy: [{ students: { _count: 'desc' } }, { name: 'asc' }],
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
expect(result.items[0]).toMatchObject({ lastAssignmentAt: createdAt });
|
|
44
|
+
expect(result.items[1]).toMatchObject({ lastAssignmentAt: null });
|
|
45
|
+
expect(result.items[0]).not.toHaveProperty('assignments');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getClassForAdmin', () => {
|
|
50
|
+
test('scopes roster submission counts to the class and flags suspected members', async () => {
|
|
51
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
52
|
+
id: 'c1',
|
|
53
|
+
name: 'Bio',
|
|
54
|
+
teachers: [
|
|
55
|
+
{
|
|
56
|
+
id: 't1',
|
|
57
|
+
username: 'ada',
|
|
58
|
+
email: 'ada@example.com',
|
|
59
|
+
verified: true,
|
|
60
|
+
_count: { teacherIn: 1, studentIn: 0, submissions: 0 },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
students: [
|
|
64
|
+
{
|
|
65
|
+
id: 's1',
|
|
66
|
+
username: 'devin-bot',
|
|
67
|
+
email: 'bot@riverside.edu',
|
|
68
|
+
verified: false,
|
|
69
|
+
_count: { teacherIn: 0, studentIn: 1, submissions: 4 },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
assignments: [],
|
|
73
|
+
announcements: [],
|
|
74
|
+
_count: { students: 1, teachers: 1 },
|
|
75
|
+
});
|
|
76
|
+
prismaMocks.submission.groupBy.mockResolvedValue([
|
|
77
|
+
{ studentId: 's1', _count: { _all: 2 } },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const cls = await getClassForAdmin('c1');
|
|
81
|
+
|
|
82
|
+
expect(prismaMocks.submission.groupBy).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ where: { assignment: { classId: 'c1' } } }),
|
|
84
|
+
);
|
|
85
|
+
expect(cls.students[0]).toMatchObject({
|
|
86
|
+
submissionsInClass: 2,
|
|
87
|
+
signals: { suspected: true },
|
|
88
|
+
});
|
|
89
|
+
expect(cls.teachers[0]).toMatchObject({
|
|
90
|
+
submissionsInClass: 0,
|
|
91
|
+
signals: { suspected: false },
|
|
92
|
+
});
|
|
93
|
+
expect(cls.suspectedMembers).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('throws NOT_FOUND for a missing class', async () => {
|
|
97
|
+
prismaMocks.class.findUnique.mockResolvedValue(null);
|
|
98
|
+
await expect(getClassForAdmin('nope')).rejects.toMatchObject({
|
|
99
|
+
code: 'NOT_FOUND',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
4
|
+
prisma: { user: { count: vi.fn(), findMany: vi.fn() }, $queryRaw: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
classifyEmail,
|
|
9
|
+
classifyUser,
|
|
10
|
+
suspectedUserWhere,
|
|
11
|
+
} from '../../../src/services/adminSpam.js';
|
|
12
|
+
|
|
13
|
+
const activeCounts = { teacherIn: 1, studentIn: 0, submissions: 3 };
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete process.env.ADMIN_SPAM_DOMAINS;
|
|
17
|
+
delete process.env.ADMIN_SPAM_BOT_PATTERNS;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('classifyUser', () => {
|
|
21
|
+
test('flags the blocked riverside.edu domain and its subdomains', () => {
|
|
22
|
+
expect(
|
|
23
|
+
classifyUser({
|
|
24
|
+
username: 'jane',
|
|
25
|
+
email: 'Jane@Riverside.edu',
|
|
26
|
+
verified: true,
|
|
27
|
+
_count: activeCounts,
|
|
28
|
+
}).reasons,
|
|
29
|
+
).toEqual(['BLOCKED_DOMAIN']);
|
|
30
|
+
|
|
31
|
+
expect(
|
|
32
|
+
classifyUser({
|
|
33
|
+
username: 'jane',
|
|
34
|
+
email: 'jane@mail.riverside.edu',
|
|
35
|
+
verified: true,
|
|
36
|
+
_count: activeCounts,
|
|
37
|
+
}).suspected,
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('does not flag domains that merely end with the blocked one', () => {
|
|
42
|
+
expect(
|
|
43
|
+
classifyUser({
|
|
44
|
+
username: 'jane',
|
|
45
|
+
email: 'jane@notriverside.edu',
|
|
46
|
+
verified: true,
|
|
47
|
+
_count: activeCounts,
|
|
48
|
+
}).suspected,
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('flags bot accounts by username or email pattern', () => {
|
|
53
|
+
expect(
|
|
54
|
+
classifyUser({
|
|
55
|
+
username: 'devin-test-1',
|
|
56
|
+
email: 'real@school.org',
|
|
57
|
+
verified: true,
|
|
58
|
+
_count: activeCounts,
|
|
59
|
+
}).reasons,
|
|
60
|
+
).toContain('BOT_PATTERN');
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
classifyUser({
|
|
64
|
+
username: 'ada',
|
|
65
|
+
email: 'ada+devin@school.org',
|
|
66
|
+
verified: true,
|
|
67
|
+
_count: activeCounts,
|
|
68
|
+
}).reasons,
|
|
69
|
+
).toContain('BOT_PATTERN');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('flags unverified accounts with no classes and no submissions', () => {
|
|
73
|
+
expect(
|
|
74
|
+
classifyUser({
|
|
75
|
+
username: 'ghost',
|
|
76
|
+
email: 'ghost@school.org',
|
|
77
|
+
verified: false,
|
|
78
|
+
_count: { teacherIn: 0, studentIn: 0, submissions: 0 },
|
|
79
|
+
}).reasons,
|
|
80
|
+
).toEqual(['NEVER_VERIFIED_INACTIVE']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('leaves real accounts alone', () => {
|
|
84
|
+
expect(
|
|
85
|
+
classifyUser({
|
|
86
|
+
username: 'ada',
|
|
87
|
+
email: 'ada@school.org',
|
|
88
|
+
verified: true,
|
|
89
|
+
_count: activeCounts,
|
|
90
|
+
}),
|
|
91
|
+
).toEqual({ suspected: false, reasons: [] });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('rules are overridable through env', () => {
|
|
95
|
+
process.env.ADMIN_SPAM_DOMAINS = 'spam.test';
|
|
96
|
+
expect(classifyEmail('a@spam.test').suspected).toBe(true);
|
|
97
|
+
expect(classifyEmail('a@riverside.edu').suspected).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('suspectedUserWhere', () => {
|
|
102
|
+
test('never matches on the inactivity heuristic, only on rule matches', () => {
|
|
103
|
+
const where = JSON.stringify(suspectedUserWhere());
|
|
104
|
+
expect(where).toContain('riverside.edu');
|
|
105
|
+
expect(where).toContain('devin');
|
|
106
|
+
expect(where).not.toContain('verified');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertReadOnlySql,
|
|
4
|
+
buildBoundedQuery,
|
|
5
|
+
serializeValue,
|
|
6
|
+
} from '../../../src/services/adminSql.js';
|
|
7
|
+
|
|
8
|
+
describe('assertReadOnlySql', () => {
|
|
9
|
+
it('accepts read statements and strips the trailing semicolon', () => {
|
|
10
|
+
expect(assertReadOnlySql('SELECT 1;')).toBe('SELECT 1');
|
|
11
|
+
expect(assertReadOnlySql(' with t as (select 1) select * from t ')).toBe(
|
|
12
|
+
'with t as (select 1) select * from t',
|
|
13
|
+
);
|
|
14
|
+
expect(assertReadOnlySql('EXPLAIN SELECT * FROM "User"')).toBe('EXPLAIN SELECT * FROM "User"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects writes, DDL and data-modifying CTEs', () => {
|
|
18
|
+
expect(() => assertReadOnlySql('DELETE FROM "User"')).toThrow(/only/i);
|
|
19
|
+
expect(() => assertReadOnlySql('DROP TABLE "User"')).toThrow(/only/i);
|
|
20
|
+
expect(() =>
|
|
21
|
+
assertReadOnlySql('WITH x AS (DELETE FROM "User" RETURNING id) SELECT * FROM x'),
|
|
22
|
+
).toThrow(/DELETE/);
|
|
23
|
+
expect(() => assertReadOnlySql('SELECT 1; DROP TABLE "User"')).toThrow(/single statement/i);
|
|
24
|
+
expect(() => assertReadOnlySql('SELECT pg_sleep(60)')).toThrow(/PG_SLEEP/);
|
|
25
|
+
expect(() => assertReadOnlySql(' ')).toThrow(/empty/i);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not flag keywords inside strings, comments or identifiers', () => {
|
|
29
|
+
expect(assertReadOnlySql("SELECT * FROM \"User\" WHERE username = 'delete-me'")).toContain(
|
|
30
|
+
'delete-me',
|
|
31
|
+
);
|
|
32
|
+
expect(assertReadOnlySql('SELECT 1 -- drop table users')).toBe('SELECT 1 -- drop table users');
|
|
33
|
+
expect(assertReadOnlySql('SELECT "settings" FROM "User" OFFSET 5')).toContain('OFFSET');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('buildBoundedQuery', () => {
|
|
38
|
+
it('wraps row-returning statements in a capped subquery', () => {
|
|
39
|
+
expect(buildBoundedQuery('SELECT * FROM "User"', 10)).toBe(
|
|
40
|
+
'SELECT * FROM (SELECT * FROM "User") AS admin_sql_console LIMIT 10',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('leaves EXPLAIN and SHOW untouched', () => {
|
|
45
|
+
expect(buildBoundedQuery('EXPLAIN SELECT 1', 10)).toBe('EXPLAIN SELECT 1');
|
|
46
|
+
expect(buildBoundedQuery('SHOW statement_timeout', 10)).toBe('SHOW statement_timeout');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('serializeValue', () => {
|
|
51
|
+
it('converts values the JSON transformer cannot handle', () => {
|
|
52
|
+
expect(serializeValue(12n)).toBe('12');
|
|
53
|
+
expect(serializeValue(new Date('2026-01-02T03:04:05.000Z'))).toBe('2026-01-02T03:04:05.000Z');
|
|
54
|
+
expect(serializeValue({ nested: [1n, null] })).toEqual({ nested: ['1', null] });
|
|
55
|
+
});
|
|
56
|
+
});
|