@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
@@ -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
+ }
@@ -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
- await prisma.runJob.update({
112
- where: { id: runJobId },
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
- .update({
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
+ });