@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.
Files changed (222) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +31 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/instrument.js +9 -4
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/fileUpload.d.ts +15 -0
  10. package/dist/lib/fileUpload.d.ts.map +1 -1
  11. package/dist/lib/fileUpload.js +43 -2
  12. package/dist/lib/fileUpload.js.map +1 -1
  13. package/dist/models/cloudStorage.d.ts +11 -0
  14. package/dist/models/cloudStorage.d.ts.map +1 -1
  15. package/dist/models/cloudStorage.js +18 -2
  16. package/dist/models/cloudStorage.js.map +1 -1
  17. package/dist/models/conversation.d.ts +1 -0
  18. package/dist/models/conversation.d.ts.map +1 -1
  19. package/dist/models/labChat.d.ts +40 -0
  20. package/dist/models/labChat.d.ts.map +1 -1
  21. package/dist/models/labChat.js +32 -2
  22. package/dist/models/labChat.js.map +1 -1
  23. package/dist/models/message.d.ts +3 -0
  24. package/dist/models/message.d.ts.map +1 -1
  25. package/dist/pipelines/aiLabChat.d.ts +2 -0
  26. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +60 -16
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  30. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.js +30 -2
  32. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +30 -5
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/generateVisualization.d.ts +33 -0
  37. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  38. package/dist/pipelines/generateVisualization.js +79 -0
  39. package/dist/pipelines/generateVisualization.js.map +1 -0
  40. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +13 -14
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +1118 -70
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/_app.js +4 -2
  47. package/dist/routers/_app.js.map +1 -1
  48. package/dist/routers/admin.d.ts +297 -1
  49. package/dist/routers/admin.d.ts.map +1 -1
  50. package/dist/routers/admin.js +43 -2
  51. package/dist/routers/admin.js.map +1 -1
  52. package/dist/routers/class.d.ts.map +1 -1
  53. package/dist/routers/class.js +16 -3
  54. package/dist/routers/class.js.map +1 -1
  55. package/dist/routers/conversation.d.ts +1 -0
  56. package/dist/routers/conversation.d.ts.map +1 -1
  57. package/dist/routers/labChat.d.ts +10 -0
  58. package/dist/routers/labChat.d.ts.map +1 -1
  59. package/dist/routers/labChat.js +6 -3
  60. package/dist/routers/labChat.js.map +1 -1
  61. package/dist/routers/message.d.ts +37 -4
  62. package/dist/routers/message.d.ts.map +1 -1
  63. package/dist/routers/message.js +24 -7
  64. package/dist/routers/message.js.map +1 -1
  65. package/dist/routers/module.d.ts +1 -1
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routers/visualization.d.ts +124 -0
  75. package/dist/routers/visualization.d.ts.map +1 -0
  76. package/dist/routers/visualization.js +41 -0
  77. package/dist/routers/visualization.js.map +1 -0
  78. package/dist/routes/aiStream.js +32 -2
  79. package/dist/routes/aiStream.js.map +1 -1
  80. package/dist/seedDatabase.d.ts.map +1 -1
  81. package/dist/seedDatabase.js +107 -3
  82. package/dist/seedDatabase.js.map +1 -1
  83. package/dist/services/adminClass.d.ts +153 -0
  84. package/dist/services/adminClass.d.ts.map +1 -0
  85. package/dist/services/adminClass.js +196 -0
  86. package/dist/services/adminClass.js.map +1 -0
  87. package/dist/services/adminInstitution.d.ts +2 -0
  88. package/dist/services/adminInstitution.d.ts.map +1 -1
  89. package/dist/services/adminInstitution.js +11 -4
  90. package/dist/services/adminInstitution.js.map +1 -1
  91. package/dist/services/adminMetrics.d.ts +8 -0
  92. package/dist/services/adminMetrics.d.ts.map +1 -1
  93. package/dist/services/adminMetrics.js +30 -3
  94. package/dist/services/adminMetrics.js.map +1 -1
  95. package/dist/services/adminSpam.d.ts +76 -0
  96. package/dist/services/adminSpam.d.ts.map +1 -0
  97. package/dist/services/adminSpam.js +163 -0
  98. package/dist/services/adminSpam.js.map +1 -0
  99. package/dist/services/adminSql.d.ts +36 -0
  100. package/dist/services/adminSql.d.ts.map +1 -0
  101. package/dist/services/adminSql.js +172 -0
  102. package/dist/services/adminSql.js.map +1 -0
  103. package/dist/services/adminTelemetry.d.ts +56 -0
  104. package/dist/services/adminTelemetry.d.ts.map +1 -0
  105. package/dist/services/adminTelemetry.js +147 -0
  106. package/dist/services/adminTelemetry.js.map +1 -0
  107. package/dist/services/adminUser.d.ts +45 -1
  108. package/dist/services/adminUser.d.ts.map +1 -1
  109. package/dist/services/adminUser.js +53 -13
  110. package/dist/services/adminUser.js.map +1 -1
  111. package/dist/services/aiChatStream.d.ts +15 -0
  112. package/dist/services/aiChatStream.d.ts.map +1 -1
  113. package/dist/services/aiChatStream.js +144 -74
  114. package/dist/services/aiChatStream.js.map +1 -1
  115. package/dist/services/aiGeneration.d.ts +4 -0
  116. package/dist/services/aiGeneration.d.ts.map +1 -0
  117. package/dist/services/aiGeneration.js +88 -0
  118. package/dist/services/aiGeneration.js.map +1 -0
  119. package/dist/services/aiProposalTools.d.ts +4 -1
  120. package/dist/services/aiProposalTools.d.ts.map +1 -1
  121. package/dist/services/aiProposalTools.js +128 -3
  122. package/dist/services/aiProposalTools.js.map +1 -1
  123. package/dist/services/aiProposals.d.ts +70 -0
  124. package/dist/services/aiProposals.d.ts.map +1 -0
  125. package/dist/services/aiProposals.js +363 -0
  126. package/dist/services/aiProposals.js.map +1 -0
  127. package/dist/services/auth.d.ts.map +1 -1
  128. package/dist/services/auth.js +10 -4
  129. package/dist/services/auth.js.map +1 -1
  130. package/dist/services/conversation.d.ts +1 -0
  131. package/dist/services/conversation.d.ts.map +1 -1
  132. package/dist/services/fees.d.ts.map +1 -1
  133. package/dist/services/fees.js +7 -3
  134. package/dist/services/fees.js.map +1 -1
  135. package/dist/services/labChat.d.ts +10 -0
  136. package/dist/services/labChat.d.ts.map +1 -1
  137. package/dist/services/labChat.js +52 -3
  138. package/dist/services/labChat.js.map +1 -1
  139. package/dist/services/message.d.ts +24 -8
  140. package/dist/services/message.d.ts.map +1 -1
  141. package/dist/services/message.js +170 -117
  142. package/dist/services/message.js.map +1 -1
  143. package/dist/services/module.d.ts.map +1 -1
  144. package/dist/services/module.js +23 -4
  145. package/dist/services/module.js.map +1 -1
  146. package/dist/services/parentPortal.d.ts +14 -0
  147. package/dist/services/parentPortal.d.ts.map +1 -1
  148. package/dist/services/parentPortal.js +72 -2
  149. package/dist/services/parentPortal.js.map +1 -1
  150. package/dist/services/studentProgress.d.ts +50 -0
  151. package/dist/services/studentProgress.d.ts.map +1 -1
  152. package/dist/services/studentProgress.js +107 -2
  153. package/dist/services/studentProgress.js.map +1 -1
  154. package/dist/services/visualization.d.ts +72 -0
  155. package/dist/services/visualization.d.ts.map +1 -0
  156. package/dist/services/visualization.js +122 -0
  157. package/dist/services/visualization.js.map +1 -0
  158. package/dist/utils/inference.d.ts +10 -0
  159. package/dist/utils/inference.d.ts.map +1 -1
  160. package/dist/utils/inference.js +77 -2
  161. package/dist/utils/inference.js.map +1 -1
  162. package/dist/utils/sentryTrpc.d.ts +3 -0
  163. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  164. package/dist/utils/sentryTrpc.js +26 -0
  165. package/dist/utils/sentryTrpc.js.map +1 -0
  166. package/dist/workers/processors/createRunJobWorker.js +17 -16
  167. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  168. package/package.json +1 -1
  169. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  170. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  171. package/prisma/schema.prisma +32 -0
  172. package/src/index.ts +30 -4
  173. package/src/instrument.ts +8 -2
  174. package/src/lib/fileUpload.ts +49 -0
  175. package/src/models/cloudStorage.ts +19 -0
  176. package/src/models/labChat.ts +34 -0
  177. package/src/pipelines/aiLabChat.ts +79 -18
  178. package/src/pipelines/aiLabChatContract.ts +35 -0
  179. package/src/pipelines/aiNewtonChat.ts +34 -2
  180. package/src/pipelines/generateVisualization.ts +107 -0
  181. package/src/pipelines/labChatPrompt.ts +11 -12
  182. package/src/routers/_app.ts +2 -0
  183. package/src/routers/admin.ts +72 -0
  184. package/src/routers/class.ts +14 -1
  185. package/src/routers/labChat.ts +5 -0
  186. package/src/routers/message.ts +34 -5
  187. package/src/routers/parent.ts +11 -0
  188. package/src/routers/studentProgress.ts +15 -0
  189. package/src/routers/visualization.ts +60 -0
  190. package/src/routes/aiStream.ts +32 -0
  191. package/src/seedDatabase.ts +107 -2
  192. package/src/services/adminClass.ts +222 -0
  193. package/src/services/adminInstitution.ts +9 -2
  194. package/src/services/adminMetrics.ts +35 -0
  195. package/src/services/adminSpam.ts +224 -0
  196. package/src/services/adminSql.ts +214 -0
  197. package/src/services/adminTelemetry.ts +194 -0
  198. package/src/services/adminUser.ts +66 -11
  199. package/src/services/aiChatStream.ts +83 -7
  200. package/src/services/aiGeneration.ts +92 -0
  201. package/src/services/aiProposalTools.ts +166 -1
  202. package/src/services/aiProposals.ts +428 -0
  203. package/src/services/auth.ts +8 -2
  204. package/src/services/fees.ts +5 -1
  205. package/src/services/labChat.ts +65 -0
  206. package/src/services/message.ts +205 -142
  207. package/src/services/module.ts +29 -2
  208. package/src/services/parentPortal.ts +85 -0
  209. package/src/services/studentProgress.ts +135 -0
  210. package/src/services/visualization.ts +147 -0
  211. package/src/utils/inference.ts +85 -0
  212. package/src/utils/sentryTrpc.ts +25 -0
  213. package/src/workers/processors/createRunJobWorker.ts +17 -17
  214. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  215. package/tests/unit/services/adminClass.test.ts +102 -0
  216. package/tests/unit/services/adminSpam.test.ts +108 -0
  217. package/tests/unit/services/adminSql.test.ts +56 -0
  218. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  219. package/tests/unit/services/adminUser.test.ts +77 -0
  220. package/tests/unit/services/visualization.test.ts +130 -0
  221. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
  222. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -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
- markSuggestionCreated: protectedProcedure
125
+ resolveAiProposal: protectedProcedure
117
126
  .input(
118
127
  z.object({
119
128
  messageId: z.string(),
120
- type: z.enum(["assignment", "worksheet", "section"]),
121
- index: z.number().int().min(0),
129
+ proposalId: z.string(),
130
+ action: z.enum(["approve", "dismiss"]),
122
131
  })
123
132
  )
124
133
  .mutation(({ ctx, input }) =>
125
- markSuggestionCreated(ctx.user!.id, input)
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
  });
@@ -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
+ });
@@ -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) {
@@ -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: {