@studious-lms/server 1.12.0 → 1.14.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 (338) hide show
  1. package/.dockerignore +28 -0
  2. package/.env.example +5 -0
  3. package/.github/workflows/deploy.yml +123 -0
  4. package/Dockerfile +54 -0
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts.map +1 -1
  8. package/dist/lib/config/cors.js +31 -11
  9. package/dist/lib/config/cors.js.map +1 -1
  10. package/dist/lib/config/env.d.ts +17 -0
  11. package/dist/lib/config/env.d.ts.map +1 -1
  12. package/dist/lib/config/env.js +8 -2
  13. package/dist/lib/config/env.js.map +1 -1
  14. package/dist/lib/googleCloudStorage.d.ts +10 -0
  15. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  16. package/dist/lib/googleCloudStorage.js +22 -2
  17. package/dist/lib/googleCloudStorage.js.map +1 -1
  18. package/dist/lib/prisma.d.ts +104 -5
  19. package/dist/lib/prisma.d.ts.map +1 -1
  20. package/dist/lib/prisma.js +33 -2
  21. package/dist/lib/prisma.js.map +1 -1
  22. package/dist/models/agenda.d.ts +20 -20
  23. package/dist/models/announcement.d.ts +177 -69
  24. package/dist/models/announcement.d.ts.map +1 -1
  25. package/dist/models/assignment.d.ts +551 -353
  26. package/dist/models/assignment.d.ts.map +1 -1
  27. package/dist/models/attendance.d.ts +197 -71
  28. package/dist/models/attendance.d.ts.map +1 -1
  29. package/dist/models/auth.d.ts +348 -114
  30. package/dist/models/auth.d.ts.map +1 -1
  31. package/dist/models/class.d.ts +324 -188
  32. package/dist/models/class.d.ts.map +1 -1
  33. package/dist/models/class.js +9 -3
  34. package/dist/models/class.js.map +1 -1
  35. package/dist/models/cloudStorage.d.ts +146 -56
  36. package/dist/models/cloudStorage.d.ts.map +1 -1
  37. package/dist/models/comment.d.ts +269 -110
  38. package/dist/models/comment.d.ts.map +1 -1
  39. package/dist/models/comment.js +3 -2
  40. package/dist/models/comment.js.map +1 -1
  41. package/dist/models/conversation.d.ts +206 -63
  42. package/dist/models/conversation.d.ts.map +1 -1
  43. package/dist/models/conversation.js +13 -2
  44. package/dist/models/conversation.js.map +1 -1
  45. package/dist/models/department.d.ts +196 -70
  46. package/dist/models/department.d.ts.map +1 -1
  47. package/dist/models/event.d.ts +287 -125
  48. package/dist/models/event.d.ts.map +1 -1
  49. package/dist/models/file.d.ts +389 -263
  50. package/dist/models/file.d.ts.map +1 -1
  51. package/dist/models/folder.d.ts +433 -181
  52. package/dist/models/folder.d.ts.map +1 -1
  53. package/dist/models/labChat.d.ts +209 -83
  54. package/dist/models/labChat.d.ts.map +1 -1
  55. package/dist/models/marketing.d.ts +80 -26
  56. package/dist/models/marketing.d.ts.map +1 -1
  57. package/dist/models/message.d.ts +95 -41
  58. package/dist/models/message.d.ts.map +1 -1
  59. package/dist/models/newtonChat.d.ts +83 -29
  60. package/dist/models/newtonChat.d.ts.map +1 -1
  61. package/dist/models/notification.d.ts +61 -25
  62. package/dist/models/notification.d.ts.map +1 -1
  63. package/dist/models/school.d.ts +349 -133
  64. package/dist/models/school.d.ts.map +1 -1
  65. package/dist/models/section.d.ts +216 -72
  66. package/dist/models/section.d.ts.map +1 -1
  67. package/dist/models/tag.d.ts +156 -48
  68. package/dist/models/tag.d.ts.map +1 -1
  69. package/dist/models/user.d.ts +82 -28
  70. package/dist/models/user.d.ts.map +1 -1
  71. package/dist/models/worksheet.d.ts +1023 -410
  72. package/dist/models/worksheet.d.ts.map +1 -1
  73. package/dist/models/worksheet.js +19 -7
  74. package/dist/models/worksheet.js.map +1 -1
  75. package/dist/pipelines/aiLabChat.d.ts +0 -8
  76. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  77. package/dist/pipelines/aiLabChat.js +50 -67
  78. package/dist/pipelines/aiLabChat.js.map +1 -1
  79. package/dist/pipelines/aiLabChatContract.d.ts +393 -388
  80. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  81. package/dist/pipelines/aiLabChatContract.js +63 -69
  82. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  83. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  84. package/dist/pipelines/aiNewtonChat.js +41 -41
  85. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  86. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  87. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  88. package/dist/pipelines/gradeWorksheet.js +59 -14
  89. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  90. package/dist/pipelines/labChatPrompt.d.ts +10 -8
  91. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  92. package/dist/pipelines/labChatPrompt.js +47 -67
  93. package/dist/pipelines/labChatPrompt.js.map +1 -1
  94. package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
  95. package/dist/routers/_app.d.ts +2268 -1898
  96. package/dist/routers/_app.d.ts.map +1 -1
  97. package/dist/routers/_app.js +4 -2
  98. package/dist/routers/_app.js.map +1 -1
  99. package/dist/routers/admin.d.ts +13 -13
  100. package/dist/routers/agenda.d.ts +18 -18
  101. package/dist/routers/announcement.d.ts +32 -28
  102. package/dist/routers/announcement.d.ts.map +1 -1
  103. package/dist/routers/announcement.js +6 -5
  104. package/dist/routers/announcement.js.map +1 -1
  105. package/dist/routers/assignment.d.ts +335 -327
  106. package/dist/routers/assignment.d.ts.map +1 -1
  107. package/dist/routers/assignment.js +4 -4
  108. package/dist/routers/assignment.js.map +1 -1
  109. package/dist/routers/attendance.d.ts +10 -10
  110. package/dist/routers/auth.d.ts +2 -2
  111. package/dist/routers/class.d.ts +170 -147
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/comment.d.ts +40 -16
  114. package/dist/routers/comment.d.ts.map +1 -1
  115. package/dist/routers/comment.js +6 -3
  116. package/dist/routers/comment.js.map +1 -1
  117. package/dist/routers/conversation.d.ts +17 -17
  118. package/dist/routers/department.d.ts +34 -34
  119. package/dist/routers/discussion.d.ts +65 -53
  120. package/dist/routers/discussion.d.ts.map +1 -1
  121. package/dist/routers/discussion.js +6 -2
  122. package/dist/routers/discussion.js.map +1 -1
  123. package/dist/routers/embedding.d.ts +69 -0
  124. package/dist/routers/embedding.d.ts.map +1 -0
  125. package/dist/routers/embedding.js +143 -0
  126. package/dist/routers/embedding.js.map +1 -0
  127. package/dist/routers/event.d.ts +39 -39
  128. package/dist/routers/file.d.ts +12 -12
  129. package/dist/routers/folder.d.ts +38 -38
  130. package/dist/routers/labChat.d.ts +19 -19
  131. package/dist/routers/marketing.d.ts +2 -2
  132. package/dist/routers/message.d.ts +13 -13
  133. package/dist/routers/newtonChat.d.ts +4 -4
  134. package/dist/routers/notifications.d.ts +9 -9
  135. package/dist/routers/permissions.d.ts +14 -14
  136. package/dist/routers/school.d.ts +55 -55
  137. package/dist/routers/search.d.ts +22 -1
  138. package/dist/routers/search.d.ts.map +1 -1
  139. package/dist/routers/search.js +9 -3
  140. package/dist/routers/search.js.map +1 -1
  141. package/dist/routers/section.d.ts +4 -4
  142. package/dist/routers/studentProgress.d.ts +1 -1
  143. package/dist/routers/tag.d.ts +7 -7
  144. package/dist/routers/user.d.ts +10 -10
  145. package/dist/routers/worksheet.d.ts +97 -56
  146. package/dist/routers/worksheet.d.ts.map +1 -1
  147. package/dist/routers/worksheet.js +28 -2
  148. package/dist/routers/worksheet.js.map +1 -1
  149. package/dist/routes/aiStream.d.ts +2 -0
  150. package/dist/routes/aiStream.d.ts.map +1 -0
  151. package/dist/routes/aiStream.js +696 -0
  152. package/dist/routes/aiStream.js.map +1 -0
  153. package/dist/seedDatabase.d.ts +2 -2
  154. package/dist/seedDatabase.js +6 -6
  155. package/dist/seedDatabase.js.map +1 -1
  156. package/dist/services/adminInstitution.d.ts +8 -8
  157. package/dist/services/adminRunJob.d.ts +4 -4
  158. package/dist/services/agenda.d.ts +18 -18
  159. package/dist/services/aiChatStream.d.ts +68 -0
  160. package/dist/services/aiChatStream.d.ts.map +1 -0
  161. package/dist/services/aiChatStream.js +157 -0
  162. package/dist/services/aiChatStream.js.map +1 -0
  163. package/dist/services/aiModifierTools.d.ts +32 -0
  164. package/dist/services/aiModifierTools.d.ts.map +1 -0
  165. package/dist/services/aiModifierTools.js +165 -0
  166. package/dist/services/aiModifierTools.js.map +1 -0
  167. package/dist/services/aiProposalTools.d.ts +32 -0
  168. package/dist/services/aiProposalTools.d.ts.map +1 -0
  169. package/dist/services/aiProposalTools.js +207 -0
  170. package/dist/services/aiProposalTools.js.map +1 -0
  171. package/dist/services/aiStream.d.ts +22 -0
  172. package/dist/services/aiStream.d.ts.map +1 -0
  173. package/dist/services/aiStream.js +142 -0
  174. package/dist/services/aiStream.js.map +1 -0
  175. package/dist/services/aiTools.d.ts +16 -0
  176. package/dist/services/aiTools.d.ts.map +1 -0
  177. package/dist/services/aiTools.js +271 -0
  178. package/dist/services/aiTools.js.map +1 -0
  179. package/dist/services/announcement.d.ts +12 -12
  180. package/dist/services/announcement.d.ts.map +1 -1
  181. package/dist/services/announcement.js +7 -2
  182. package/dist/services/announcement.js.map +1 -1
  183. package/dist/services/assignment.d.ts +293 -293
  184. package/dist/services/assignment.d.ts.map +1 -1
  185. package/dist/services/assignment.js +36 -15
  186. package/dist/services/assignment.js.map +1 -1
  187. package/dist/services/attendance.d.ts +10 -10
  188. package/dist/services/auth.d.ts +2 -2
  189. package/dist/services/class.d.ts +161 -138
  190. package/dist/services/class.d.ts.map +1 -1
  191. package/dist/services/class.js +25 -4
  192. package/dist/services/class.js.map +1 -1
  193. package/dist/services/comment.d.ts +34 -16
  194. package/dist/services/comment.d.ts.map +1 -1
  195. package/dist/services/comment.js +23 -2
  196. package/dist/services/comment.js.map +1 -1
  197. package/dist/services/conversation.d.ts +17 -17
  198. package/dist/services/conversation.d.ts.map +1 -1
  199. package/dist/services/conversation.js +9 -7
  200. package/dist/services/conversation.js.map +1 -1
  201. package/dist/services/department.d.ts +33 -33
  202. package/dist/services/discussion.d.ts +58 -58
  203. package/dist/services/discussion.d.ts.map +1 -1
  204. package/dist/services/discussion.js +8 -8
  205. package/dist/services/discussion.js.map +1 -1
  206. package/dist/services/discussionSummary.d.ts +21 -0
  207. package/dist/services/discussionSummary.d.ts.map +1 -0
  208. package/dist/services/discussionSummary.js +179 -0
  209. package/dist/services/discussionSummary.js.map +1 -0
  210. package/dist/services/embedding.d.ts +67 -0
  211. package/dist/services/embedding.d.ts.map +1 -0
  212. package/dist/services/embedding.js +226 -0
  213. package/dist/services/embedding.js.map +1 -0
  214. package/dist/services/event.d.ts +37 -37
  215. package/dist/services/file.d.ts +24 -24
  216. package/dist/services/folder.d.ts +36 -36
  217. package/dist/services/labChat.d.ts +19 -19
  218. package/dist/services/marketing.d.ts +2 -2
  219. package/dist/services/message.d.ts +11 -11
  220. package/dist/services/message.d.ts.map +1 -1
  221. package/dist/services/message.js +2 -2
  222. package/dist/services/message.js.map +1 -1
  223. package/dist/services/newtonChat.d.ts +4 -4
  224. package/dist/services/notification.d.ts +9 -9
  225. package/dist/services/notification.d.ts.map +1 -1
  226. package/dist/services/permissions.d.ts +14 -14
  227. package/dist/services/school.d.ts +50 -50
  228. package/dist/services/search.d.ts +12 -0
  229. package/dist/services/search.d.ts.map +1 -1
  230. package/dist/services/search.js +120 -2
  231. package/dist/services/search.js.map +1 -1
  232. package/dist/services/section.d.ts +3 -3
  233. package/dist/services/tag.d.ts +4 -4
  234. package/dist/services/user.d.ts +4 -4
  235. package/dist/services/user.d.ts.map +1 -1
  236. package/dist/services/user.js +10 -15
  237. package/dist/services/user.js.map +1 -1
  238. package/dist/services/worksheet.d.ts +62 -47
  239. package/dist/services/worksheet.d.ts.map +1 -1
  240. package/dist/services/worksheet.js +11 -4
  241. package/dist/services/worksheet.js.map +1 -1
  242. package/dist/utils/streamBroadcast.d.ts +11 -0
  243. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  244. package/dist/utils/streamBroadcast.js +32 -0
  245. package/dist/utils/streamBroadcast.js.map +1 -0
  246. package/dist/workers/connection.d.ts +15 -0
  247. package/dist/workers/connection.d.ts.map +1 -1
  248. package/dist/workers/connection.js +20 -2
  249. package/dist/workers/connection.js.map +1 -1
  250. package/dist/workers/index.js +4 -2
  251. package/dist/workers/index.js.map +1 -1
  252. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  253. package/dist/workers/processors/createRunJobWorker.js +31 -8
  254. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  255. package/dist/workers/processors/embedContent.d.ts +8 -0
  256. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  257. package/dist/workers/processors/embedContent.js +76 -0
  258. package/dist/workers/processors/embedContent.js.map +1 -0
  259. package/dist/workers/queues.d.ts +6 -0
  260. package/dist/workers/queues.d.ts.map +1 -1
  261. package/dist/workers/queues.js +5 -3
  262. package/dist/workers/queues.js.map +1 -1
  263. package/package.json +5 -1
  264. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  265. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  266. package/prisma/schema.prisma +39 -7
  267. package/src/index.ts +4 -0
  268. package/src/lib/config/cors.ts +32 -9
  269. package/src/lib/config/env.ts +6 -0
  270. package/src/lib/googleCloudStorage.ts +21 -0
  271. package/src/lib/prisma.ts +40 -1
  272. package/src/models/class.ts +10 -3
  273. package/src/models/comment.ts +1 -0
  274. package/src/models/conversation.ts +12 -0
  275. package/src/models/worksheet.ts +16 -7
  276. package/src/pipelines/aiLabChat.ts +54 -79
  277. package/src/pipelines/aiLabChatContract.ts +69 -69
  278. package/src/pipelines/aiNewtonChat.ts +46 -43
  279. package/src/pipelines/gradeWorksheet.ts +66 -15
  280. package/src/pipelines/labChatPrompt.ts +63 -88
  281. package/src/routers/_app.ts +2 -0
  282. package/src/routers/announcement.ts +4 -3
  283. package/src/routers/assignment.ts +2 -2
  284. package/src/routers/comment.ts +5 -0
  285. package/src/routers/discussion.ts +7 -0
  286. package/src/routers/embedding.ts +159 -0
  287. package/src/routers/search.ts +10 -1
  288. package/src/routers/worksheet.ts +29 -0
  289. package/src/routes/aiStream.ts +789 -0
  290. package/src/seedDatabase.ts +4 -4
  291. package/src/services/aiChatStream.ts +219 -0
  292. package/src/services/aiModifierTools.ts +188 -0
  293. package/src/services/aiProposalTools.ts +272 -0
  294. package/src/services/aiStream.ts +175 -0
  295. package/src/services/aiTools.ts +290 -0
  296. package/src/services/announcement.ts +7 -0
  297. package/src/services/assignment.ts +36 -14
  298. package/src/services/class.ts +30 -2
  299. package/src/services/comment.ts +22 -0
  300. package/src/services/conversation.ts +9 -4
  301. package/src/services/discussion.ts +9 -8
  302. package/src/services/discussionSummary.ts +226 -0
  303. package/src/services/embedding.ts +285 -0
  304. package/src/services/message.ts +2 -2
  305. package/src/services/search.ts +138 -0
  306. package/src/services/user.ts +9 -17
  307. package/src/services/worksheet.ts +10 -2
  308. package/src/utils/streamBroadcast.ts +48 -0
  309. package/src/workers/connection.ts +19 -0
  310. package/src/workers/index.ts +2 -0
  311. package/src/workers/processors/createRunJobWorker.ts +32 -6
  312. package/src/workers/processors/embedContent.ts +90 -0
  313. package/src/workers/queues.ts +5 -1
  314. package/tests/integration/routers/conversation.test.ts +9 -7
  315. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  316. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  317. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  318. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  319. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  320. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  321. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  322. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  323. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  324. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  325. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  326. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  327. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  328. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  329. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  330. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  331. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  332. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  333. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  334. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  335. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  336. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  337. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  338. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -5,15 +5,7 @@ import { TRPCError } from "@trpc/server";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { env } from "../lib/config/env.js";
7
7
  import { findUserById, findUserProfileByUserId, upsertUserProfile } from "../models/user.js";
8
-
9
- function getFileUrl(filePath: string | null): string | null {
10
- if (!filePath) return null;
11
- if (filePath.startsWith("http")) {
12
- return filePath;
13
- }
14
- const backendUrl = env.BACKEND_URL || "http://localhost:3001";
15
- return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
16
- }
8
+ import { makeFilePublic } from "../lib/googleCloudStorage.js";
17
9
 
18
10
  /** Get user profile with display info and profile picture URLs. */
19
11
  export async function getProfile(userId: string) {
@@ -36,10 +28,8 @@ export async function getProfile(userId: string) {
36
28
  bio: (userProfile as any).bio || null,
37
29
  location: (userProfile as any).location || null,
38
30
  website: (userProfile as any).website || null,
39
- profilePicture: getFileUrl((userProfile as any).profilePicture),
40
- profilePictureThumbnail: getFileUrl(
41
- (userProfile as any).profilePictureThumbnail
42
- ),
31
+ profilePicture: (userProfile as any).profilePicture ?? null,
32
+ profilePictureThumbnail: (userProfile as any).profilePictureThumbnail ?? null,
43
33
  }
44
34
  : {
45
35
  displayName: null,
@@ -78,6 +68,10 @@ export async function updateProfile(
78
68
  try {
79
69
  profilePictureUrl = input.profilePicture.filePath;
80
70
  profilePictureThumbnail = null;
71
+
72
+ // Best-effort: make the uploaded file publicly readable so the public
73
+ // GCS URL resolved by the Prisma extension actually works.
74
+ await makeFilePublic(profilePictureUrl);
81
75
  } catch (error) {
82
76
  logger.error("Profile picture processing failed", {
83
77
  userId,
@@ -125,10 +119,8 @@ export async function updateProfile(
125
119
  bio: (updatedProfile as any).bio || null,
126
120
  location: (updatedProfile as any).location || null,
127
121
  website: (updatedProfile as any).website || null,
128
- profilePicture: getFileUrl((updatedProfile as any).profilePicture),
129
- profilePictureThumbnail: getFileUrl(
130
- (updatedProfile as any).profilePictureThumbnail
131
- ),
122
+ profilePicture: (updatedProfile as any).profilePicture ?? null,
123
+ profilePictureThumbnail: (updatedProfile as any).profilePictureThumbnail ?? null,
132
124
  },
133
125
  };
134
126
  }
@@ -38,6 +38,7 @@ import {
38
38
  findStudentQuestionProgressByIdForStudentOrTeacher,
39
39
  findStudentQuestionProgressByIdForTeacherAndResponse,
40
40
  } from "../models/worksheet.js";
41
+ import { enqueueEmbedding } from "./embedding.js";
41
42
 
42
43
  type MCQOptions = { id: string; text: string; isCorrect: boolean }[];
43
44
 
@@ -108,7 +109,10 @@ export async function updateWorksheetRecord(
108
109
  message: "Only class teachers can update worksheets",
109
110
  });
110
111
  }
111
- return updateWorksheet(worksheetId, data);
112
+ const updated = await updateWorksheet(worksheetId, data);
113
+ // Re-index worksheet for RAG search
114
+ enqueueEmbedding(worksheet.classId, 'WORKSHEET', worksheetId);
115
+ return updated;
112
116
  }
113
117
 
114
118
  export async function deleteWorksheetRecord(worksheetId: string, userId: string) {
@@ -123,7 +127,10 @@ export async function deleteWorksheetRecord(worksheetId: string, userId: string)
123
127
  }
124
128
 
125
129
  export async function createWorksheetRecord(classId: string, name: string) {
126
- return createWorksheet({ classId, name });
130
+ const worksheet = await createWorksheet({ classId, name });
131
+ // Auto-index worksheet for RAG search
132
+ enqueueEmbedding(classId, 'WORKSHEET', worksheet.id);
133
+ return worksheet;
127
134
  }
128
135
 
129
136
  /**
@@ -300,6 +307,7 @@ export async function addQuestionToWorksheet(
300
307
  options?: object;
301
308
  markScheme?: object;
302
309
  type: WorksheetQuestionType;
310
+ order?: number;
303
311
  }
304
312
  ) {
305
313
  const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Broadcast text content as sequential Pusher chunks so connected clients
3
+ * can render progressive streaming even when the underlying inference call
4
+ * returns the full text at once (structured-output pipelines).
5
+ */
6
+ import { pusher, chatChannel } from '../lib/pusher.js';
7
+
8
+ const CHUNK_SIZE = 80;
9
+
10
+ interface BroadcastTextChunksOptions {
11
+ /** When true, skip the `message-stream-complete` event at the end.
12
+ * Callers that need to broadcast additional events (e.g. tool-call
13
+ * indicators) before the stream ends should set this to `true` and
14
+ * call {@link broadcastStreamComplete} explicitly when done. */
15
+ skipComplete?: boolean;
16
+ }
17
+
18
+ export async function broadcastTextChunks(
19
+ conversationId: string,
20
+ messageId: string,
21
+ text: string,
22
+ options?: BroadcastTextChunksOptions,
23
+ ): Promise<void> {
24
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
25
+ const chunk = text.slice(i, i + CHUNK_SIZE);
26
+ await pusher
27
+ .trigger(chatChannel(conversationId), 'message-stream-chunk', {
28
+ messageId,
29
+ chunk,
30
+ })
31
+ .catch(() => {});
32
+ }
33
+
34
+ if (!options?.skipComplete) {
35
+ await broadcastStreamComplete(conversationId, messageId);
36
+ }
37
+ }
38
+
39
+ export async function broadcastStreamComplete(
40
+ conversationId: string,
41
+ messageId: string,
42
+ ): Promise<void> {
43
+ await pusher
44
+ .trigger(chatChannel(conversationId), 'message-stream-complete', {
45
+ messageId,
46
+ })
47
+ .catch(() => {});
48
+ }
@@ -35,3 +35,22 @@ function buildBullConnection(): IORedis {
35
35
  }
36
36
 
37
37
  export const bullConnection = buildBullConnection();
38
+
39
+ /**
40
+ * Key prefix applied to every BullMQ Queue and Worker.
41
+ *
42
+ * Without this, BullMQ defaults to the `bull` namespace, so ANY process
43
+ * pointed at the same Redis instance shares the same queues. That is a
44
+ * problem when a single Redis (e.g. a shared Upstash instance) is used across
45
+ * environments while each environment has its own database: a worker can pick
46
+ * up a job enqueued elsewhere, fail to find its `RunJob` row, and crash-loop
47
+ * with "No record was found for an update".
48
+ *
49
+ * Namespacing by environment keeps each environment's jobs isolated. Override
50
+ * with `BULL_PREFIX` when you need finer-grained separation (e.g. per
51
+ * developer sharing one Redis).
52
+ */
53
+ export function getBullPrefix(): string {
54
+ const explicit = env.BULL_PREFIX?.trim();
55
+ return explicit && explicit.length > 0 ? explicit : `bull:${env.NODE_ENV}`;
56
+ }
@@ -17,6 +17,7 @@ import { aiNewtonChatWorker } from './processors/aiNewtonChat.js';
17
17
  import { gradeWorksheetWorker } from './processors/gradeWorksheet.js';
18
18
  import { pdfToWorksheetWorker } from './processors/pdfToWorksheet.js';
19
19
  import { studentProgressChatWorker } from './processors/studentProgressChat.js';
20
+ import { embedContentWorker } from './processors/embedContent.js';
20
21
 
21
22
  const workers: Worker<any>[] = [
22
23
  pdfToWorksheetWorker,
@@ -24,6 +25,7 @@ const workers: Worker<any>[] = [
24
25
  aiLabChatWorker,
25
26
  aiNewtonChatWorker,
26
27
  studentProgressChatWorker,
28
+ embedContentWorker,
27
29
  ];
28
30
 
29
31
  logger.info('Workers ready', {
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { Worker, type Job, type WorkerOptions } from 'bullmq';
19
19
  import { Prisma, type RunJobType } from '@prisma/client';
20
- import { bullConnection } from '../connection.js';
20
+ import { bullConnection, getBullPrefix } from '../connection.js';
21
21
  import { prisma } from '../../lib/prisma.js';
22
22
  import { logger } from '../../utils/logger.js';
23
23
  import { QUEUE_NAMES, type JobInputs } from '../queues.js';
@@ -69,7 +69,21 @@ export function createRunJobWorker<T extends RunJobType>({
69
69
  select: { status: true, result: true, userId: true, classId: true },
70
70
  });
71
71
 
72
- if (existing?.status === 'SUCCEEDED') {
72
+ if (!existing) {
73
+ // Orphaned job: the RunJob row this job points at does not exist in
74
+ // THIS database. Typically caused by a Redis instance shared across
75
+ // environments/databases (the `prefix` namespacing in this file's
76
+ // Worker options is the primary guard against that), or by a DB reset
77
+ // that left jobs behind in Redis. Discard instead of throwing on the
78
+ // `update` below and burning all retry attempts.
79
+ logger.warn(`${name} RunJob not found; discarding orphaned job`, {
80
+ runJobId,
81
+ bullJobId: job.id,
82
+ });
83
+ return null;
84
+ }
85
+
86
+ if (existing.status === 'SUCCEEDED') {
73
87
  logger.info(`${name} RunJob already succeeded; skipping re-run`, {
74
88
  runJobId,
75
89
  bullJobId: job.id,
@@ -81,7 +95,7 @@ export function createRunJobWorker<T extends RunJobType>({
81
95
  where: { id: runJobId },
82
96
  data: {
83
97
  status: 'RUNNING',
84
- startedAt: existing?.status === 'RUNNING' ? undefined : new Date(),
98
+ startedAt: existing.status === 'RUNNING' ? undefined : new Date(),
85
99
  attempts: { increment: 1 },
86
100
  },
87
101
  });
@@ -90,8 +104,8 @@ export function createRunJobWorker<T extends RunJobType>({
90
104
  runJobId,
91
105
  bullJobId: job.id,
92
106
  attempt: job.attemptsMade + 1,
93
- userId: existing?.userId ?? null,
94
- classId: existing?.classId ?? null,
107
+ userId: existing.userId ?? null,
108
+ classId: existing.classId ?? null,
95
109
  });
96
110
 
97
111
  await prisma.runJob.update({
@@ -106,7 +120,7 @@ export function createRunJobWorker<T extends RunJobType>({
106
120
 
107
121
  return result;
108
122
  },
109
- { connection: bullConnection, ...workerOptions },
123
+ { connection: bullConnection, prefix: getBullPrefix(), ...workerOptions },
110
124
  );
111
125
 
112
126
  worker.on('error', (err) => {
@@ -143,6 +157,18 @@ export function createRunJobWorker<T extends RunJobType>({
143
157
  },
144
158
  })
145
159
  .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
+ }
146
172
  logger.error(`Failed to record ${name} failure on RunJob`, {
147
173
  runJobId,
148
174
  error:
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Background worker for embedding content into pgvector.
3
+ * Handles indexing of files, assignments, worksheets, and announcements.
4
+ */
5
+ import { createRunJobWorker } from './createRunJobWorker.js';
6
+ import {
7
+ indexAssignment,
8
+ indexWorksheet,
9
+ indexAnnouncement,
10
+ indexContent,
11
+ } from '../../services/embedding.js';
12
+ import { prisma } from '../../lib/prisma.js';
13
+ import { logger } from '../../utils/logger.js';
14
+ import type { EmbeddingSourceType } from '@prisma/client';
15
+
16
+ export const embedContentWorker = createRunJobWorker({
17
+ type: 'EMBED_CONTENT',
18
+ workerOptions: {
19
+ concurrency: 3,
20
+ limiter: { max: 10, duration: 60_000 },
21
+ },
22
+ run: async ({ classId, sourceType, sourceId }) => {
23
+ const typedSourceType = sourceType as EmbeddingSourceType;
24
+
25
+ switch (typedSourceType) {
26
+ case 'ASSIGNMENT':
27
+ await indexAssignment(classId, sourceId);
28
+ break;
29
+
30
+ case 'WORKSHEET':
31
+ await indexWorksheet(classId, sourceId);
32
+ break;
33
+
34
+ case 'ANNOUNCEMENT':
35
+ await indexAnnouncement(classId, sourceId);
36
+ break;
37
+
38
+ case 'FILE': {
39
+ // For files, fetch the file metadata and content
40
+ const file = await prisma.file.findUnique({
41
+ where: { id: sourceId },
42
+ select: { name: true, path: true, type: true },
43
+ });
44
+
45
+ if (!file) {
46
+ logger.warn('File not found for embedding', { sourceId });
47
+ return { skipped: true, reason: 'file_not_found' };
48
+ }
49
+
50
+ // For now, index the filename and any text-based content.
51
+ // PDF extraction would be added here with pdf-parse.
52
+ const text = `File: ${file.name}\nType: ${file.type}\nPath: ${file.path}`;
53
+ await indexContent({
54
+ classId,
55
+ sourceType: 'FILE',
56
+ sourceId,
57
+ text,
58
+ metadata: { name: file.name, type: file.type },
59
+ });
60
+ break;
61
+ }
62
+
63
+ case 'SECTION': {
64
+ const section = await prisma.section.findUnique({
65
+ where: { id: sourceId },
66
+ select: { name: true },
67
+ });
68
+
69
+ if (!section) {
70
+ return { skipped: true, reason: 'section_not_found' };
71
+ }
72
+
73
+ await indexContent({
74
+ classId,
75
+ sourceType: 'SECTION',
76
+ sourceId,
77
+ text: `Section: ${section.name}`,
78
+ metadata: { name: section.name },
79
+ });
80
+ break;
81
+ }
82
+
83
+ default:
84
+ logger.warn('Unknown embedding source type', { sourceType, sourceId });
85
+ return { skipped: true, reason: 'unknown_source_type' };
86
+ }
87
+
88
+ return { indexed: true, sourceType, sourceId };
89
+ },
90
+ });
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Queue, type JobsOptions } from 'bullmq';
10
10
  import { Prisma, type RunJobType } from '@prisma/client';
11
- import { bullConnection } from './connection.js';
11
+ import { bullConnection, getBullPrefix } from './connection.js';
12
12
  import { prisma } from '../lib/prisma.js';
13
13
  import { logger } from '../utils/logger.js';
14
14
 
@@ -47,6 +47,8 @@ export type JobInputs = {
47
47
  */
48
48
  AI_NEWTON_CHAT: { conversationId: string; aiMessageId: string };
49
49
  STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };
50
+ /** Embed content into pgvector for RAG search. */
51
+ EMBED_CONTENT: { classId: string; sourceType: string; sourceId: string };
50
52
  };
51
53
 
52
54
  export const QUEUE_NAMES: Record<RunJobType, string> = {
@@ -55,6 +57,7 @@ export const QUEUE_NAMES: Record<RunJobType, string> = {
55
57
  AI_LAB_CHAT: 'aiLabChat',
56
58
  AI_NEWTON_CHAT: 'aiNewtonChat',
57
59
  STUDENT_PROGRESS_CHAT: 'studentProgressChat',
60
+ EMBED_CONTENT: 'embedContent',
58
61
  };
59
62
 
60
63
  /**
@@ -68,6 +71,7 @@ function getQueue(type: RunJobType): Queue {
68
71
  if (!queue) {
69
72
  queue = new Queue(QUEUE_NAMES[type], {
70
73
  connection: bullConnection,
74
+ prefix: getBullPrefix(),
71
75
  defaultJobOptions: DEFAULT_JOB_OPTIONS,
72
76
  });
73
77
  queueCache.set(type, queue);
@@ -64,13 +64,15 @@ describe('Conversation Router', () => {
64
64
  expect(conversation.type).toBe('GROUP');
65
65
  });
66
66
 
67
- test('should fail to create duplicate DM', async () => {
68
- await expect(
69
- user1Caller.conversation.create({
70
- type: 'DM',
71
- memberIds: ['testuser2'],
72
- }),
73
- ).rejects.toThrow();
67
+ test('should return existing DM instead of creating duplicate', async () => {
68
+ const existingConversation = await user1Caller.conversation.create({
69
+ type: 'DM',
70
+ memberIds: ['testuser2'],
71
+ });
72
+
73
+ expect(existingConversation).toBeDefined();
74
+ expect(existingConversation.id).toBe(dmConversation.id);
75
+ expect(existingConversation.members.length).toBe(2);
74
76
  });
75
77
 
76
78
  test('should fail to create GROUP without name', async () => {
@@ -1,32 +1,49 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { labChatArrayFieldInstructions, labChatResponseFormat, labChatResponseSchema } from "../../../src/pipelines/aiLabChatContract.js";
2
+ import {
3
+ assignmentToCreateSchema,
4
+ sectionToCreateSchema,
5
+ worksheetQuestionSchema,
6
+ } from "../../../src/pipelines/aiLabChatContract.js";
3
7
 
4
8
  describe("aiLabChat contract", () => {
5
- test("defaults worksheet and section collections to empty arrays", () => {
6
- const parsed = labChatResponseSchema.parse({
7
- text: "Summary",
8
- docs: null,
9
- assignmentsToCreate: null,
9
+ test("worksheet questions default options, mark scheme, and points", () => {
10
+ const parsed = worksheetQuestionSchema.parse({
11
+ type: "SHORT_ANSWER",
12
+ question: "What is osmosis?",
13
+ answer: "Diffusion of water across a membrane",
14
+ order: 0,
10
15
  });
11
16
 
12
- expect(parsed.worksheetsToCreate).toEqual([]);
13
- expect(parsed.sectionsToCreate).toEqual([]);
17
+ expect(parsed.options).toEqual([]);
18
+ expect(parsed.markScheme).toEqual([]);
19
+ expect(parsed.points).toBe(0);
14
20
  });
15
21
 
16
- test("rejects null worksheet and section collections", () => {
17
- expect(() => labChatResponseSchema.parse({
18
- text: "Summary",
19
- docs: null,
20
- worksheetsToCreate: null,
21
- sectionsToCreate: null,
22
- assignmentsToCreate: null,
23
- })).toThrow();
22
+ test("section color must be a hex value when provided", () => {
23
+ expect(() => sectionToCreateSchema.parse({ name: "Unit 1", color: "blue" })).toThrow();
24
+ expect(sectionToCreateSchema.parse({ name: "Unit 1", color: "#3B82F6" }).color).toBe(
25
+ "#3B82F6",
26
+ );
27
+ expect(sectionToCreateSchema.parse({ name: "Unit 1" }).color).toBeUndefined();
24
28
  });
25
29
 
26
- test("prompt instructions require arrays for worksheet and section fields", () => {
27
- expect(labChatResponseFormat).toContain(`"worksheetsToCreate": array`);
28
- expect(labChatResponseFormat).toContain(`"sectionsToCreate": array`);
29
- expect(labChatArrayFieldInstructions).toContain(`Use [] when there are no worksheets to create.`);
30
- expect(labChatArrayFieldInstructions).toContain(`Use [] when there are no sections to create.`);
30
+ test("assignment requires an ISO datetime due date", () => {
31
+ const base = {
32
+ title: "Osmosis Quiz",
33
+ instructions: "Answer all questions.",
34
+ acceptFiles: false,
35
+ acceptExtendedResponse: false,
36
+ acceptWorksheet: true,
37
+ maxGrade: 10,
38
+ worksheetIds: [],
39
+ studentIds: [],
40
+ type: "QUIZ" as const,
41
+ attachments: [],
42
+ };
43
+
44
+ expect(() => assignmentToCreateSchema.parse({ ...base, dueDate: "not-a-date" })).toThrow();
45
+ expect(
46
+ assignmentToCreateSchema.parse({ ...base, dueDate: "2026-04-08T12:00:00Z" }).dueDate,
47
+ ).toBe("2026-04-08T12:00:00Z");
31
48
  });
32
49
  });
@@ -1,26 +1,24 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
 
3
3
  import {
4
- buildLabChatResponseMessages,
5
- buildLabChatSystemPrompt,
4
+ buildLabChatAgentMessages,
5
+ buildLabChatAgentSystemPrompt,
6
6
  } from "../../../src/pipelines/labChatPrompt";
7
- import { labChatArrayFieldInstructions, labChatResponseFormat } from "../../../src/pipelines/aiLabChatContract";
8
7
 
9
- describe("buildLabChatSystemPrompt", () => {
10
- test("requires arrays for worksheet and section structured output fields", () => {
11
- const prompt = buildLabChatSystemPrompt("Context");
8
+ describe("buildLabChatAgentSystemPrompt", () => {
9
+ test("instructs the model to create content via proposal tools", () => {
10
+ const prompt = buildLabChatAgentSystemPrompt("Context");
12
11
 
13
- expect(prompt).toContain(labChatResponseFormat);
14
- expect(prompt).toContain(labChatArrayFieldInstructions);
15
- expect(prompt).toContain('Do not return null for "worksheetsToCreate" or "sectionsToCreate".');
16
- expect(prompt).not.toContain('"worksheetsToCreate": null | array');
17
- expect(prompt).not.toContain('"sectionsToCreate": null | array');
12
+ expect(prompt).toContain("Context");
13
+ expect(prompt).toContain("HOW TO CREATE CONTENT (TOOLS):");
14
+ expect(prompt).toContain("propose_assignment");
15
+ expect(prompt).toContain("propose_worksheet");
18
16
  });
19
17
  });
20
18
 
21
- describe("buildLabChatResponseMessages", () => {
22
- test("builds response messages from history without replaying the latest teacher turn", () => {
23
- const messages = buildLabChatResponseMessages({
19
+ describe("buildLabChatAgentMessages", () => {
20
+ test("folds the class context into the system prompt, not the message list", () => {
21
+ const { system, messages } = buildLabChatAgentMessages({
24
22
  context: "Topic context",
25
23
  classContext: "CLASS: Biology",
26
24
  recentMessages: [
@@ -32,30 +30,19 @@ describe("buildLabChatResponseMessages", () => {
32
30
  profile: { displayName: "Ms Patel" },
33
31
  },
34
32
  },
35
- {
36
- senderId: "ai-user",
37
- content: "I can do that.",
38
- },
39
33
  ],
40
34
  isAIUser: (senderId) => senderId === "ai-user",
41
35
  });
42
36
 
43
- const teacherTurns = messages.filter(
44
- (message) =>
45
- message.role === "user" &&
46
- typeof message.content === "string" &&
47
- message.content.includes("Create a quiz on osmosis"),
48
- );
49
-
50
- expect(teacherTurns).toHaveLength(1);
51
- expect(messages.at(-2)).toMatchObject({
52
- role: "developer",
53
- content: expect.stringContaining("CLASS CONTEXT"),
54
- });
37
+ expect(system).toContain("CLASS CONTEXT");
38
+ expect(system).toContain("CLASS: Biology");
39
+ expect(messages).toEqual([
40
+ { role: "user", content: "Ms Patel: Create a quiz on osmosis" },
41
+ ]);
55
42
  });
56
43
 
57
44
  test("sorts timestamped history into chronological order before prompt assembly", () => {
58
- const messages = buildLabChatResponseMessages({
45
+ const { messages } = buildLabChatAgentMessages({
59
46
  context: "Topic context",
60
47
  classContext: "CLASS: Biology",
61
48
  recentMessages: [
@@ -79,18 +66,18 @@ describe("buildLabChatResponseMessages", () => {
79
66
  isAIUser: (senderId) => senderId === "ai-user",
80
67
  });
81
68
 
82
- expect(messages[1]).toMatchObject({
69
+ expect(messages[0]).toMatchObject({
83
70
  role: "user",
84
71
  content: "Ms Patel: First request",
85
72
  });
86
- expect(messages[2]).toMatchObject({
73
+ expect(messages[1]).toMatchObject({
87
74
  role: "assistant",
88
75
  content: "Second response",
89
76
  });
90
77
  });
91
78
 
92
79
  test("keeps original order among messages without timestamps", () => {
93
- const messages = buildLabChatResponseMessages({
80
+ const { messages } = buildLabChatAgentMessages({
94
81
  context: "Topic context",
95
82
  classContext: "CLASS: Biology",
96
83
  recentMessages: [
@@ -114,11 +101,11 @@ describe("buildLabChatResponseMessages", () => {
114
101
  isAIUser: () => false,
115
102
  });
116
103
 
117
- expect(messages[1]).toMatchObject({
104
+ expect(messages[0]).toMatchObject({
118
105
  role: "user",
119
106
  content: "Ms Patel: First untimed request",
120
107
  });
121
- expect(messages[2]).toMatchObject({
108
+ expect(messages[1]).toMatchObject({
122
109
  role: "user",
123
110
  content: "Ms Patel: Second untimed request",
124
111
  });