@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
package/src/routers/message.ts
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
confirmMessageUpload,
|
|
10
10
|
markAsRead,
|
|
11
11
|
markMentionsAsRead,
|
|
12
|
-
markSuggestionCreated,
|
|
13
12
|
getUnreadCount,
|
|
13
|
+
regenerateAIResponse,
|
|
14
|
+
setAIMessageFeedback,
|
|
14
15
|
} from "../services/message.js";
|
|
16
|
+
import { resolveAiProposal } from "../services/aiProposals.js";
|
|
17
|
+
import { cancelAiGeneration } from "../services/aiGeneration.js";
|
|
15
18
|
|
|
16
19
|
export const messageRouter = createTRPCRouter({
|
|
17
20
|
list: protectedProcedure
|
|
@@ -101,6 +104,12 @@ export const messageRouter = createTRPCRouter({
|
|
|
101
104
|
.input(z.object({ messageId: z.string() }))
|
|
102
105
|
.mutation(({ ctx, input }) => deleteMessage(ctx.user!.id, input.messageId)),
|
|
103
106
|
|
|
107
|
+
cancelAiGeneration: protectedProcedure
|
|
108
|
+
.input(z.object({ messageId: z.string() }))
|
|
109
|
+
.mutation(({ ctx, input }) =>
|
|
110
|
+
cancelAiGeneration(ctx.user!.id, input.messageId)
|
|
111
|
+
),
|
|
112
|
+
|
|
104
113
|
markAsRead: protectedProcedure
|
|
105
114
|
.input(z.object({ conversationId: z.string() }))
|
|
106
115
|
.mutation(({ ctx, input }) =>
|
|
@@ -113,16 +122,16 @@ export const messageRouter = createTRPCRouter({
|
|
|
113
122
|
markMentionsAsRead(ctx.user!.id, input.conversationId)
|
|
114
123
|
),
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
resolveAiProposal: protectedProcedure
|
|
117
126
|
.input(
|
|
118
127
|
z.object({
|
|
119
128
|
messageId: z.string(),
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
proposalId: z.string(),
|
|
130
|
+
action: z.enum(["approve", "dismiss"]),
|
|
122
131
|
})
|
|
123
132
|
)
|
|
124
133
|
.mutation(({ ctx, input }) =>
|
|
125
|
-
|
|
134
|
+
resolveAiProposal(ctx.user!.id, input)
|
|
126
135
|
),
|
|
127
136
|
|
|
128
137
|
getUnreadCount: protectedProcedure
|
|
@@ -130,4 +139,24 @@ export const messageRouter = createTRPCRouter({
|
|
|
130
139
|
.query(({ ctx, input }) =>
|
|
131
140
|
getUnreadCount(ctx.user!.id, input.conversationId)
|
|
132
141
|
),
|
|
142
|
+
|
|
143
|
+
regenerateAIResponse: protectedProcedure
|
|
144
|
+
.input(z.object({ messageId: z.string() }))
|
|
145
|
+
.mutation(({ ctx, input }) =>
|
|
146
|
+
regenerateAIResponse(ctx.user!.id, input.messageId)
|
|
147
|
+
),
|
|
148
|
+
|
|
149
|
+
setAIFeedback: protectedProcedure
|
|
150
|
+
.input(
|
|
151
|
+
z.object({
|
|
152
|
+
messageId: z.string(),
|
|
153
|
+
feedback: z.enum(["UP", "DOWN"]).nullable(),
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
.mutation(({ ctx, input }) =>
|
|
157
|
+
setAIMessageFeedback(ctx.user!.id, {
|
|
158
|
+
messageId: input.messageId,
|
|
159
|
+
feedback: input.feedback,
|
|
160
|
+
})
|
|
161
|
+
),
|
|
133
162
|
});
|
package/src/routers/parent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
3
3
|
import {
|
|
4
|
+
addParentToStudent,
|
|
4
5
|
getChildAttendance,
|
|
5
6
|
getChildGrades,
|
|
6
7
|
linkParentToStudent,
|
|
@@ -20,6 +21,16 @@ export const parentRouter = createTRPCRouter({
|
|
|
20
21
|
)
|
|
21
22
|
.mutation(({ ctx, input }) => linkParentToStudent(ctx.user!.id, input)),
|
|
22
23
|
|
|
24
|
+
addToStudent: protectedProcedure
|
|
25
|
+
.input(
|
|
26
|
+
z.object({
|
|
27
|
+
schoolId: z.string(),
|
|
28
|
+
studentUserId: z.string(),
|
|
29
|
+
parentIdentifier: z.string().min(1),
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
.mutation(({ ctx, input }) => addParentToStudent(ctx.user!.id, input)),
|
|
33
|
+
|
|
23
34
|
unlinkStudent: protectedProcedure
|
|
24
35
|
.input(
|
|
25
36
|
z.object({
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
chatAboutStudentProgress,
|
|
9
9
|
dismissStudentRecommendation,
|
|
10
|
+
generateFollowUpAssignment,
|
|
10
11
|
getClassProgressRecommendations,
|
|
11
12
|
getStudentProgressFocus,
|
|
12
13
|
getStudentProgressForecast,
|
|
@@ -66,6 +67,20 @@ export const studentProgressRouter = createTRPCRouter({
|
|
|
66
67
|
dismissStudentRecommendation(ctx.user!.id, input),
|
|
67
68
|
),
|
|
68
69
|
|
|
70
|
+
generateFollowUpAssignment: protectedTeacherProcedure
|
|
71
|
+
.input(
|
|
72
|
+
z.object({
|
|
73
|
+
classId: z.string(),
|
|
74
|
+
studentId: z.string(),
|
|
75
|
+
submissionId: z.string(),
|
|
76
|
+
prompt: z.string().min(1).max(2000),
|
|
77
|
+
dueDate: z.string().datetime().optional(),
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
.mutation(({ ctx, input }) =>
|
|
81
|
+
generateFollowUpAssignment(ctx.user!.id, input),
|
|
82
|
+
),
|
|
83
|
+
|
|
69
84
|
chat: protectedClassMemberProcedure
|
|
70
85
|
.input(
|
|
71
86
|
progressInputSchema.extend({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
createTRPCRouter,
|
|
4
|
+
protectedClassMemberProcedure,
|
|
5
|
+
protectedTeacherProcedure,
|
|
6
|
+
} from "../trpc.js";
|
|
7
|
+
import {
|
|
8
|
+
createVisualization,
|
|
9
|
+
deleteVisualization,
|
|
10
|
+
getVisualization,
|
|
11
|
+
listVisualizations,
|
|
12
|
+
reviseVisualizationRecord,
|
|
13
|
+
updateVisualization,
|
|
14
|
+
} from "../services/visualization.js";
|
|
15
|
+
|
|
16
|
+
export const visualizationRouter = createTRPCRouter({
|
|
17
|
+
list: protectedClassMemberProcedure
|
|
18
|
+
.input(z.object({ classId: z.string() }))
|
|
19
|
+
.query(({ ctx, input }) => listVisualizations(ctx.user!.id, input.classId)),
|
|
20
|
+
|
|
21
|
+
get: protectedClassMemberProcedure
|
|
22
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
23
|
+
.query(({ ctx, input }) => getVisualization(ctx.user!.id, input.classId, input.id)),
|
|
24
|
+
|
|
25
|
+
generate: protectedTeacherProcedure
|
|
26
|
+
.input(
|
|
27
|
+
z.object({
|
|
28
|
+
classId: z.string(),
|
|
29
|
+
prompt: z.string().trim().min(3).max(4000),
|
|
30
|
+
title: z.string().trim().max(120).optional(),
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.mutation(({ ctx, input }) => createVisualization(ctx.user!.id, input)),
|
|
34
|
+
|
|
35
|
+
revise: protectedTeacherProcedure
|
|
36
|
+
.input(
|
|
37
|
+
z.object({
|
|
38
|
+
classId: z.string(),
|
|
39
|
+
id: z.string(),
|
|
40
|
+
instruction: z.string().trim().min(3).max(4000),
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
.mutation(({ ctx, input }) => reviseVisualizationRecord(ctx.user!.id, input)),
|
|
44
|
+
|
|
45
|
+
update: protectedTeacherProcedure
|
|
46
|
+
.input(
|
|
47
|
+
z.object({
|
|
48
|
+
classId: z.string(),
|
|
49
|
+
id: z.string(),
|
|
50
|
+
title: z.string().trim().min(1).max(120).optional(),
|
|
51
|
+
html: z.string().min(1).optional(),
|
|
52
|
+
published: z.boolean().optional(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.mutation(({ input }) => updateVisualization(input)),
|
|
56
|
+
|
|
57
|
+
delete: protectedTeacherProcedure
|
|
58
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
59
|
+
.mutation(({ input }) => deleteVisualization(input)),
|
|
60
|
+
});
|
package/src/routes/aiStream.ts
CHANGED
|
@@ -453,6 +453,33 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
453
453
|
Connection: 'keep-alive',
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
+
// Narration is mirrored to other open editors as batched `modifier-chunk`
|
|
457
|
+
// events so their overlay streams live without a Pusher trigger per token.
|
|
458
|
+
const CHUNK_FLUSH_INTERVAL_MS = 250;
|
|
459
|
+
const CHUNK_FLUSH_MAX_CHARS = 500;
|
|
460
|
+
let chunkBuffer = '';
|
|
461
|
+
let lastChunkFlush = 0;
|
|
462
|
+
const flushChunks = async (force = false) => {
|
|
463
|
+
if (!chunkBuffer) return;
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
if (
|
|
466
|
+
!force &&
|
|
467
|
+
now - lastChunkFlush < CHUNK_FLUSH_INTERVAL_MS &&
|
|
468
|
+
chunkBuffer.length < CHUNK_FLUSH_MAX_CHARS
|
|
469
|
+
) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const payload = chunkBuffer;
|
|
473
|
+
chunkBuffer = '';
|
|
474
|
+
lastChunkFlush = now;
|
|
475
|
+
await pusher.trigger(channel, 'modifier-chunk', {
|
|
476
|
+
resourceId,
|
|
477
|
+
chunk: payload,
|
|
478
|
+
}).catch((err: unknown) => {
|
|
479
|
+
logger.warn('Failed to broadcast modifier-chunk', { err, resourceId });
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
|
|
456
483
|
// Called after a tool persists a single field — apply it live everywhere.
|
|
457
484
|
const onFieldUpdate = async (update: ModifierFieldUpdate) => {
|
|
458
485
|
await pusher.trigger(channel, 'modifier-field-updated', {
|
|
@@ -466,6 +493,8 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
466
493
|
});
|
|
467
494
|
// Human-readable progress for the requesting client's bar.
|
|
468
495
|
res.write(`Updated ${update.label}.\n`);
|
|
496
|
+
chunkBuffer += `Updated ${update.label}.\n`;
|
|
497
|
+
await flushChunks();
|
|
469
498
|
};
|
|
470
499
|
|
|
471
500
|
try {
|
|
@@ -490,7 +519,10 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
490
519
|
// call onFieldUpdate); any narration text is forwarded to the SSE client.
|
|
491
520
|
for await (const chunk of result.textStream) {
|
|
492
521
|
res.write(chunk);
|
|
522
|
+
chunkBuffer += chunk;
|
|
523
|
+
await flushChunks();
|
|
493
524
|
}
|
|
525
|
+
await flushChunks(true);
|
|
494
526
|
|
|
495
527
|
// Clean up Redis flag and broadcast completion
|
|
496
528
|
if (redis) {
|
package/src/seedDatabase.ts
CHANGED
|
@@ -1653,14 +1653,119 @@ export const seedDatabase = async () => {
|
|
|
1653
1653
|
createdAt: hoursFromNow(-3, now),
|
|
1654
1654
|
},
|
|
1655
1655
|
});
|
|
1656
|
+
// AI reply with creation proposals in all three states (pending card,
|
|
1657
|
+
// approved row, dismissed row) so the approval flow is demo-able.
|
|
1658
|
+
const labIntroText =
|
|
1659
|
+
"Here's what I put together for the organelle functions unit. I drafted a 10-question quiz worksheet, and I'm proposing a graded quiz assignment plus a new section to keep the unit organized:";
|
|
1660
|
+
const labProposals = [
|
|
1661
|
+
{
|
|
1662
|
+
id: "seed-proposal-worksheet",
|
|
1663
|
+
kind: "worksheet",
|
|
1664
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1665
|
+
status: "approved",
|
|
1666
|
+
createdId: geneticsWorksheet.id,
|
|
1667
|
+
payload: {
|
|
1668
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1669
|
+
questions: Array.from({ length: 10 }, (_, i) => ({
|
|
1670
|
+
type: "MULTIPLE_CHOICE",
|
|
1671
|
+
question: `Organelle question ${i + 1}`,
|
|
1672
|
+
answer: "A",
|
|
1673
|
+
options: [],
|
|
1674
|
+
markScheme: [],
|
|
1675
|
+
points: 1,
|
|
1676
|
+
order: i,
|
|
1677
|
+
})),
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
{
|
|
1681
|
+
id: "seed-proposal-assignment",
|
|
1682
|
+
kind: "assignment",
|
|
1683
|
+
title: "Organelle Functions Quiz",
|
|
1684
|
+
status: "pending",
|
|
1685
|
+
payload: {
|
|
1686
|
+
title: "Organelle Functions Quiz",
|
|
1687
|
+
instructions:
|
|
1688
|
+
"Complete the 10-question multiple choice quiz on organelle functions. One attempt, 20 minutes.",
|
|
1689
|
+
dueDate: hoursFromNow(7 * 24, now).toISOString(),
|
|
1690
|
+
acceptFiles: false,
|
|
1691
|
+
acceptExtendedResponse: false,
|
|
1692
|
+
acceptWorksheet: true,
|
|
1693
|
+
maxGrade: 10,
|
|
1694
|
+
worksheetIds: [],
|
|
1695
|
+
studentIds: [],
|
|
1696
|
+
type: "QUIZ",
|
|
1697
|
+
attachments: [],
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
id: "seed-proposal-section",
|
|
1702
|
+
kind: "section",
|
|
1703
|
+
title: "Unit 3: Cell Structure",
|
|
1704
|
+
status: "dismissed",
|
|
1705
|
+
payload: { name: "Unit 3: Cell Structure" },
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
id: "seed-proposal-assignment-update",
|
|
1709
|
+
kind: "assignmentUpdate",
|
|
1710
|
+
title: geneticsAssignment.title,
|
|
1711
|
+
status: "pending",
|
|
1712
|
+
payload: {
|
|
1713
|
+
id: geneticsAssignment.id,
|
|
1714
|
+
targetTitle: geneticsAssignment.title,
|
|
1715
|
+
dueDate: hoursFromNow(10 * 24, now).toISOString(),
|
|
1716
|
+
maxGrade: 15,
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
];
|
|
1656
1720
|
await prisma.message.create({
|
|
1657
1721
|
data: {
|
|
1658
|
-
content:
|
|
1659
|
-
"Sure! Here are 10 AP-level MCQs on organelle functions, with explanations on each. Want me to also generate a 4-criterion rubric for grading short-answer follow-ups?",
|
|
1722
|
+
content: labIntroText,
|
|
1660
1723
|
senderId: aiUserId,
|
|
1661
1724
|
conversationId: labConversation.id,
|
|
1662
1725
|
createdAt: hoursFromNow(-2.95, now),
|
|
1663
1726
|
status: JobStatus.COMPLETED,
|
|
1727
|
+
meta: {
|
|
1728
|
+
pendingCreations: labProposals,
|
|
1729
|
+
streamParts: [
|
|
1730
|
+
{ type: "text", text: labIntroText },
|
|
1731
|
+
{
|
|
1732
|
+
type: "tool",
|
|
1733
|
+
toolCallId: "seed-tool-worksheet",
|
|
1734
|
+
toolName: "propose_worksheet",
|
|
1735
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1736
|
+
proposalId: "seed-proposal-worksheet",
|
|
1737
|
+
status: "completed",
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
type: "tool",
|
|
1741
|
+
toolCallId: "seed-tool-assignment",
|
|
1742
|
+
toolName: "propose_assignment",
|
|
1743
|
+
title: "Organelle Functions Quiz",
|
|
1744
|
+
proposalId: "seed-proposal-assignment",
|
|
1745
|
+
status: "completed",
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
type: "tool",
|
|
1749
|
+
toolCallId: "seed-tool-section",
|
|
1750
|
+
toolName: "propose_section",
|
|
1751
|
+
title: "Unit 3: Cell Structure",
|
|
1752
|
+
proposalId: "seed-proposal-section",
|
|
1753
|
+
status: "completed",
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
type: "tool",
|
|
1757
|
+
toolCallId: "seed-tool-assignment-update",
|
|
1758
|
+
toolName: "propose_update_assignment",
|
|
1759
|
+
title: geneticsAssignment.title,
|
|
1760
|
+
proposalId: "seed-proposal-assignment-update",
|
|
1761
|
+
status: "completed",
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
type: "text",
|
|
1765
|
+
text: "Approve whichever you'd like me to create — nothing is added to the class until you do.",
|
|
1766
|
+
},
|
|
1767
|
+
],
|
|
1768
|
+
},
|
|
1664
1769
|
},
|
|
1665
1770
|
});
|
|
1666
1771
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Prisma } from '@prisma/client';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { prisma } from '../lib/prisma.js';
|
|
4
|
+
import { classifyUser } from './adminSpam.js';
|
|
5
|
+
|
|
6
|
+
export type AdminClassMembership = 'all' | 'populated' | 'empty';
|
|
7
|
+
export type AdminClassSort = 'name' | 'students' | 'assignments';
|
|
8
|
+
|
|
9
|
+
export async function listClassesForAdmin(input: {
|
|
10
|
+
page: number;
|
|
11
|
+
pageSize: number;
|
|
12
|
+
query?: string;
|
|
13
|
+
schoolId?: string;
|
|
14
|
+
membership?: AdminClassMembership;
|
|
15
|
+
sort?: AdminClassSort;
|
|
16
|
+
}) {
|
|
17
|
+
const skip = input.page * input.pageSize;
|
|
18
|
+
const filters: Prisma.ClassWhereInput[] = [];
|
|
19
|
+
|
|
20
|
+
const q = input.query?.trim();
|
|
21
|
+
if (q) {
|
|
22
|
+
filters.push({
|
|
23
|
+
OR: [
|
|
24
|
+
{ name: { contains: q, mode: 'insensitive' } },
|
|
25
|
+
{ subject: { contains: q, mode: 'insensitive' } },
|
|
26
|
+
{ section: { contains: q, mode: 'insensitive' } },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (input.schoolId) filters.push({ schoolId: input.schoolId });
|
|
32
|
+
if (input.membership === 'populated') filters.push({ students: { some: {} } });
|
|
33
|
+
if (input.membership === 'empty') filters.push({ students: { none: {} } });
|
|
34
|
+
|
|
35
|
+
const where: Prisma.ClassWhereInput = filters.length ? { AND: filters } : {};
|
|
36
|
+
|
|
37
|
+
const orderBy: Prisma.ClassOrderByWithRelationInput[] =
|
|
38
|
+
input.sort === 'students'
|
|
39
|
+
? [{ students: { _count: 'desc' } }, { name: 'asc' }]
|
|
40
|
+
: input.sort === 'assignments'
|
|
41
|
+
? [{ assignments: { _count: 'desc' } }, { name: 'asc' }]
|
|
42
|
+
: [{ name: 'asc' }];
|
|
43
|
+
|
|
44
|
+
const [items, total] = await Promise.all([
|
|
45
|
+
prisma.class.findMany({
|
|
46
|
+
where,
|
|
47
|
+
orderBy,
|
|
48
|
+
skip,
|
|
49
|
+
take: input.pageSize,
|
|
50
|
+
select: {
|
|
51
|
+
id: true,
|
|
52
|
+
name: true,
|
|
53
|
+
subject: true,
|
|
54
|
+
section: true,
|
|
55
|
+
school: { select: { id: true, name: true } },
|
|
56
|
+
department: { select: { id: true, name: true } },
|
|
57
|
+
teachers: { select: { id: true, username: true }, take: 3 },
|
|
58
|
+
assignments: {
|
|
59
|
+
orderBy: { createdAt: 'desc' },
|
|
60
|
+
take: 1,
|
|
61
|
+
select: { createdAt: true },
|
|
62
|
+
},
|
|
63
|
+
_count: {
|
|
64
|
+
select: {
|
|
65
|
+
students: true,
|
|
66
|
+
teachers: true,
|
|
67
|
+
assignments: true,
|
|
68
|
+
announcements: true,
|
|
69
|
+
discussions: true,
|
|
70
|
+
worksheets: true,
|
|
71
|
+
events: true,
|
|
72
|
+
modules: true,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
prisma.class.count({ where }),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
items: items.map(({ assignments, ...rest }) => ({
|
|
82
|
+
...rest,
|
|
83
|
+
lastAssignmentAt: assignments[0]?.createdAt ?? null,
|
|
84
|
+
})),
|
|
85
|
+
total,
|
|
86
|
+
page: input.page,
|
|
87
|
+
pageSize: input.pageSize,
|
|
88
|
+
pageCount: Math.max(1, Math.ceil(total / input.pageSize)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MEMBER_SELECT = {
|
|
93
|
+
id: true,
|
|
94
|
+
username: true,
|
|
95
|
+
email: true,
|
|
96
|
+
verified: true,
|
|
97
|
+
_count: { select: { teacherIn: true, studentIn: true, submissions: true } },
|
|
98
|
+
} satisfies Prisma.UserSelect;
|
|
99
|
+
|
|
100
|
+
export async function getClassForAdmin(id: string) {
|
|
101
|
+
const cls = await prisma.class.findUnique({
|
|
102
|
+
where: { id },
|
|
103
|
+
select: {
|
|
104
|
+
id: true,
|
|
105
|
+
name: true,
|
|
106
|
+
subject: true,
|
|
107
|
+
section: true,
|
|
108
|
+
color: true,
|
|
109
|
+
school: { select: { id: true, name: true } },
|
|
110
|
+
department: { select: { id: true, name: true } },
|
|
111
|
+
teachers: { orderBy: { username: 'asc' }, select: MEMBER_SELECT },
|
|
112
|
+
students: {
|
|
113
|
+
orderBy: { username: 'asc' },
|
|
114
|
+
take: 200,
|
|
115
|
+
select: MEMBER_SELECT,
|
|
116
|
+
},
|
|
117
|
+
assignments: {
|
|
118
|
+
orderBy: { createdAt: 'desc' },
|
|
119
|
+
take: 20,
|
|
120
|
+
select: {
|
|
121
|
+
id: true,
|
|
122
|
+
title: true,
|
|
123
|
+
type: true,
|
|
124
|
+
graded: true,
|
|
125
|
+
dueDate: true,
|
|
126
|
+
createdAt: true,
|
|
127
|
+
teacher: { select: { id: true, username: true } },
|
|
128
|
+
_count: { select: { submissions: true } },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
announcements: {
|
|
132
|
+
orderBy: { createdAt: 'desc' },
|
|
133
|
+
take: 10,
|
|
134
|
+
select: {
|
|
135
|
+
id: true,
|
|
136
|
+
createdAt: true,
|
|
137
|
+
remarks: true,
|
|
138
|
+
teacher: { select: { id: true, username: true } },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
_count: {
|
|
142
|
+
select: {
|
|
143
|
+
students: true,
|
|
144
|
+
teachers: true,
|
|
145
|
+
assignments: true,
|
|
146
|
+
announcements: true,
|
|
147
|
+
discussions: true,
|
|
148
|
+
worksheets: true,
|
|
149
|
+
events: true,
|
|
150
|
+
modules: true,
|
|
151
|
+
sections: true,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!cls) {
|
|
158
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Class not found' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Per-student submission counts scoped to this class, so a roster row shows
|
|
162
|
+
// engagement here rather than platform-wide totals.
|
|
163
|
+
const submissionsByStudent = await prisma.submission.groupBy({
|
|
164
|
+
by: ['studentId'],
|
|
165
|
+
where: { assignment: { classId: id } },
|
|
166
|
+
_count: { _all: true },
|
|
167
|
+
});
|
|
168
|
+
const submissionCount = new Map(
|
|
169
|
+
submissionsByStudent.map((row) => [row.studentId, row._count._all]),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const withSignals = (user: (typeof cls.students)[number]) => ({
|
|
173
|
+
...user,
|
|
174
|
+
signals: classifyUser(user),
|
|
175
|
+
submissionsInClass: submissionCount.get(user.id) ?? 0,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
...cls,
|
|
180
|
+
teachers: cls.teachers.map(withSignals),
|
|
181
|
+
students: cls.students.map(withSignals),
|
|
182
|
+
announcements: cls.announcements.map((announcement) => ({
|
|
183
|
+
...announcement,
|
|
184
|
+
remarks:
|
|
185
|
+
announcement.remarks.length > 160
|
|
186
|
+
? `${announcement.remarks.slice(0, 160)}…`
|
|
187
|
+
: announcement.remarks,
|
|
188
|
+
})),
|
|
189
|
+
suspectedMembers: [...cls.teachers, ...cls.students].filter(
|
|
190
|
+
(user) => classifyUser(user).suspected,
|
|
191
|
+
).length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Classes a single user teaches or studies in, for the user detail page. */
|
|
196
|
+
export async function listClassesForUserForAdmin(userId: string) {
|
|
197
|
+
const select = {
|
|
198
|
+
id: true,
|
|
199
|
+
name: true,
|
|
200
|
+
subject: true,
|
|
201
|
+
section: true,
|
|
202
|
+
school: { select: { id: true, name: true } },
|
|
203
|
+
_count: { select: { students: true, assignments: true } },
|
|
204
|
+
} satisfies Prisma.ClassSelect;
|
|
205
|
+
|
|
206
|
+
const [teaching, enrolled] = await Promise.all([
|
|
207
|
+
prisma.class.findMany({
|
|
208
|
+
where: { teachers: { some: { id: userId } } },
|
|
209
|
+
orderBy: { name: 'asc' },
|
|
210
|
+
take: 100,
|
|
211
|
+
select,
|
|
212
|
+
}),
|
|
213
|
+
prisma.class.findMany({
|
|
214
|
+
where: { students: { some: { id: userId } } },
|
|
215
|
+
orderBy: { name: 'asc' },
|
|
216
|
+
take: 100,
|
|
217
|
+
select,
|
|
218
|
+
}),
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
return { teaching, enrolled };
|
|
222
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Prisma } from '@prisma/client';
|
|
2
2
|
import { TRPCError } from '@trpc/server';
|
|
3
3
|
import { prisma } from '../lib/prisma.js';
|
|
4
|
+
import { classifyEmail } from './adminSpam.js';
|
|
4
5
|
|
|
5
6
|
export async function listSchoolsForAdmin(input: {
|
|
6
7
|
page: number;
|
|
@@ -159,7 +160,10 @@ export async function listSchoolDevelopmentProgramsForAdmin(input: {
|
|
|
159
160
|
]);
|
|
160
161
|
|
|
161
162
|
return {
|
|
162
|
-
items
|
|
163
|
+
items: items.map((item) => ({
|
|
164
|
+
...item,
|
|
165
|
+
signals: classifyEmail(item.contactEmail),
|
|
166
|
+
})),
|
|
163
167
|
total,
|
|
164
168
|
page: input.page,
|
|
165
169
|
pageSize: input.pageSize,
|
|
@@ -183,7 +187,10 @@ export async function listEarlyAccessRequestsForAdmin(input: {
|
|
|
183
187
|
]);
|
|
184
188
|
|
|
185
189
|
return {
|
|
186
|
-
items
|
|
190
|
+
items: items.map((item) => ({
|
|
191
|
+
...item,
|
|
192
|
+
signals: classifyEmail(item.email),
|
|
193
|
+
})),
|
|
187
194
|
total,
|
|
188
195
|
page: input.page,
|
|
189
196
|
pageSize: input.pageSize,
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { prisma } from '../lib/prisma.js';
|
|
2
|
+
import { suspectedUserWhere } from './adminSpam.js';
|
|
3
|
+
|
|
4
|
+
async function countActiveUsersSince(since: Date) {
|
|
5
|
+
const rows = await prisma.session.groupBy({
|
|
6
|
+
by: ['userId'],
|
|
7
|
+
where: { type: 'AUTH', createdAt: { gte: since }, userId: { not: null } },
|
|
8
|
+
});
|
|
9
|
+
return rows.length;
|
|
10
|
+
}
|
|
2
11
|
|
|
3
12
|
export async function getOverviewMetricsForAdmin() {
|
|
4
13
|
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
14
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
15
|
+
const suspected = suspectedUserWhere();
|
|
5
16
|
|
|
6
17
|
const [
|
|
7
18
|
schools,
|
|
8
19
|
users,
|
|
9
20
|
verifiedUsers,
|
|
21
|
+
suspectedUsers,
|
|
22
|
+
signupsLast24h,
|
|
23
|
+
signupsLast7d,
|
|
24
|
+
activeUsersLast24h,
|
|
25
|
+
activeUsersLast7d,
|
|
10
26
|
classes,
|
|
27
|
+
activeClassesLast7d,
|
|
28
|
+
submissionsLast24h,
|
|
11
29
|
departments,
|
|
12
30
|
runJobsByStatus,
|
|
13
31
|
failedRunJobsLast24h,
|
|
@@ -17,7 +35,16 @@ export async function getOverviewMetricsForAdmin() {
|
|
|
17
35
|
prisma.school.count(),
|
|
18
36
|
prisma.user.count(),
|
|
19
37
|
prisma.user.count({ where: { verified: true } }),
|
|
38
|
+
prisma.user.count({ where: suspected }),
|
|
39
|
+
prisma.userProfile.count({ where: { createdAt: { gte: dayAgo } } }),
|
|
40
|
+
prisma.userProfile.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
41
|
+
countActiveUsersSince(dayAgo),
|
|
42
|
+
countActiveUsersSince(weekAgo),
|
|
20
43
|
prisma.class.count(),
|
|
44
|
+
prisma.class.count({
|
|
45
|
+
where: { assignments: { some: { createdAt: { gte: weekAgo } } } },
|
|
46
|
+
}),
|
|
47
|
+
prisma.submission.count({ where: { submittedAt: { gte: dayAgo } } }),
|
|
21
48
|
prisma.department.count(),
|
|
22
49
|
prisma.runJob.groupBy({ by: ['status'], _count: { _all: true } }),
|
|
23
50
|
prisma.runJob.count({
|
|
@@ -31,6 +58,14 @@ export async function getOverviewMetricsForAdmin() {
|
|
|
31
58
|
schools,
|
|
32
59
|
users,
|
|
33
60
|
verifiedUsers,
|
|
61
|
+
suspectedUsers,
|
|
62
|
+
cleanUsers: users - suspectedUsers,
|
|
63
|
+
signupsLast24h,
|
|
64
|
+
signupsLast7d,
|
|
65
|
+
activeUsersLast24h,
|
|
66
|
+
activeUsersLast7d,
|
|
67
|
+
activeClassesLast7d,
|
|
68
|
+
submissionsLast24h,
|
|
34
69
|
classes,
|
|
35
70
|
departments,
|
|
36
71
|
runJobs: {
|