@studious-lms/server 1.13.0 → 1.15.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 (375) 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/instrument.js +3 -3
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/config/cors.d.ts.map +1 -1
  10. package/dist/lib/config/cors.js +31 -11
  11. package/dist/lib/config/cors.js.map +1 -1
  12. package/dist/lib/config/env.d.ts +17 -0
  13. package/dist/lib/config/env.d.ts.map +1 -1
  14. package/dist/lib/config/env.js +8 -2
  15. package/dist/lib/config/env.js.map +1 -1
  16. package/dist/lib/googleCloudStorage.d.ts +10 -0
  17. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  18. package/dist/lib/googleCloudStorage.js +22 -2
  19. package/dist/lib/googleCloudStorage.js.map +1 -1
  20. package/dist/lib/inferDocumentFormat.d.ts +10 -0
  21. package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
  22. package/dist/lib/inferDocumentFormat.js +48 -0
  23. package/dist/lib/inferDocumentFormat.js.map +1 -0
  24. package/dist/lib/legal.d.ts +3 -0
  25. package/dist/lib/legal.d.ts.map +1 -0
  26. package/dist/lib/legal.js +6 -0
  27. package/dist/lib/legal.js.map +1 -0
  28. package/dist/lib/prisma.d.ts +104 -5
  29. package/dist/lib/prisma.d.ts.map +1 -1
  30. package/dist/lib/prisma.js +33 -2
  31. package/dist/lib/prisma.js.map +1 -1
  32. package/dist/models/agenda.d.ts +20 -20
  33. package/dist/models/announcement.d.ts +211 -101
  34. package/dist/models/announcement.d.ts.map +1 -1
  35. package/dist/models/assignment.d.ts +560 -362
  36. package/dist/models/assignment.d.ts.map +1 -1
  37. package/dist/models/attendance.d.ts +197 -71
  38. package/dist/models/attendance.d.ts.map +1 -1
  39. package/dist/models/auth.d.ts +356 -114
  40. package/dist/models/auth.d.ts.map +1 -1
  41. package/dist/models/auth.js +4 -2
  42. package/dist/models/auth.js.map +1 -1
  43. package/dist/models/class.d.ts +327 -201
  44. package/dist/models/class.d.ts.map +1 -1
  45. package/dist/models/class.js +2 -2
  46. package/dist/models/class.js.map +1 -1
  47. package/dist/models/cloudStorage.d.ts +146 -56
  48. package/dist/models/cloudStorage.d.ts.map +1 -1
  49. package/dist/models/comment.d.ts +262 -110
  50. package/dist/models/comment.d.ts.map +1 -1
  51. package/dist/models/conversation.d.ts +206 -80
  52. package/dist/models/conversation.d.ts.map +1 -1
  53. package/dist/models/department.d.ts +196 -70
  54. package/dist/models/department.d.ts.map +1 -1
  55. package/dist/models/event.d.ts +305 -141
  56. package/dist/models/event.d.ts.map +1 -1
  57. package/dist/models/file.d.ts +422 -272
  58. package/dist/models/file.d.ts.map +1 -1
  59. package/dist/models/folder.d.ts +433 -181
  60. package/dist/models/folder.d.ts.map +1 -1
  61. package/dist/models/labChat.d.ts +205 -79
  62. package/dist/models/labChat.d.ts.map +1 -1
  63. package/dist/models/marketing.d.ts +79 -38
  64. package/dist/models/marketing.d.ts.map +1 -1
  65. package/dist/models/marketing.js +12 -4
  66. package/dist/models/marketing.js.map +1 -1
  67. package/dist/models/message.d.ts +97 -43
  68. package/dist/models/message.d.ts.map +1 -1
  69. package/dist/models/newtonChat.d.ts +84 -30
  70. package/dist/models/newtonChat.d.ts.map +1 -1
  71. package/dist/models/notification.d.ts +57 -21
  72. package/dist/models/notification.d.ts.map +1 -1
  73. package/dist/models/school.d.ts +349 -133
  74. package/dist/models/school.d.ts.map +1 -1
  75. package/dist/models/section.d.ts +216 -72
  76. package/dist/models/section.d.ts.map +1 -1
  77. package/dist/models/tag.d.ts +156 -48
  78. package/dist/models/tag.d.ts.map +1 -1
  79. package/dist/models/user.d.ts +82 -28
  80. package/dist/models/user.d.ts.map +1 -1
  81. package/dist/models/worksheet.d.ts +1032 -423
  82. package/dist/models/worksheet.d.ts.map +1 -1
  83. package/dist/models/worksheet.js +8 -2
  84. package/dist/models/worksheet.js.map +1 -1
  85. package/dist/pipelines/aiLabChat.d.ts +7 -9
  86. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  87. package/dist/pipelines/aiLabChat.js +194 -125
  88. package/dist/pipelines/aiLabChat.js.map +1 -1
  89. package/dist/pipelines/aiLabChatContract.d.ts +233 -389
  90. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  91. package/dist/pipelines/aiLabChatContract.js +54 -70
  92. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  93. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  94. package/dist/pipelines/aiNewtonChat.js +41 -41
  95. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  96. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  97. package/dist/pipelines/labChatPrompt.d.ts +18 -11
  98. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  99. package/dist/pipelines/labChatPrompt.js +93 -75
  100. package/dist/pipelines/labChatPrompt.js.map +1 -1
  101. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  102. package/dist/routers/_app.d.ts +2601 -2151
  103. package/dist/routers/_app.d.ts.map +1 -1
  104. package/dist/routers/_app.js +4 -2
  105. package/dist/routers/_app.js.map +1 -1
  106. package/dist/routers/admin.d.ts +13 -13
  107. package/dist/routers/agenda.d.ts +18 -18
  108. package/dist/routers/announcement.d.ts +60 -60
  109. package/dist/routers/assignment.d.ts +393 -383
  110. package/dist/routers/assignment.d.ts.map +1 -1
  111. package/dist/routers/attendance.d.ts +10 -10
  112. package/dist/routers/auth.d.ts +4 -2
  113. package/dist/routers/auth.d.ts.map +1 -1
  114. package/dist/routers/auth.js +15 -2
  115. package/dist/routers/auth.js.map +1 -1
  116. package/dist/routers/class.d.ts +157 -153
  117. package/dist/routers/class.d.ts.map +1 -1
  118. package/dist/routers/comment.d.ts +18 -18
  119. package/dist/routers/conversation.d.ts +11 -11
  120. package/dist/routers/department.d.ts +34 -34
  121. package/dist/routers/discussion.d.ts +68 -56
  122. package/dist/routers/discussion.d.ts.map +1 -1
  123. package/dist/routers/discussion.js +6 -2
  124. package/dist/routers/discussion.js.map +1 -1
  125. package/dist/routers/embedding.d.ts +73 -0
  126. package/dist/routers/embedding.d.ts.map +1 -0
  127. package/dist/routers/embedding.js +151 -0
  128. package/dist/routers/embedding.js.map +1 -0
  129. package/dist/routers/event.d.ts +57 -55
  130. package/dist/routers/event.d.ts.map +1 -1
  131. package/dist/routers/file.d.ts +14 -14
  132. package/dist/routers/folder.d.ts +38 -38
  133. package/dist/routers/labChat.d.ts +16 -16
  134. package/dist/routers/marketing.d.ts +1 -14
  135. package/dist/routers/marketing.d.ts.map +1 -1
  136. package/dist/routers/message.d.ts +13 -13
  137. package/dist/routers/newtonChat.d.ts +4 -4
  138. package/dist/routers/notifications.d.ts +5 -5
  139. package/dist/routers/permissions.d.ts +14 -14
  140. package/dist/routers/school.d.ts +59 -55
  141. package/dist/routers/school.d.ts.map +1 -1
  142. package/dist/routers/search.d.ts +22 -1
  143. package/dist/routers/search.d.ts.map +1 -1
  144. package/dist/routers/search.js +9 -3
  145. package/dist/routers/search.js.map +1 -1
  146. package/dist/routers/section.d.ts +4 -4
  147. package/dist/routers/studentProgress.d.ts +1 -1
  148. package/dist/routers/tag.d.ts +7 -7
  149. package/dist/routers/user.d.ts +99 -10
  150. package/dist/routers/user.d.ts.map +1 -1
  151. package/dist/routers/user.js +19 -2
  152. package/dist/routers/user.js.map +1 -1
  153. package/dist/routers/worksheet.d.ts +93 -55
  154. package/dist/routers/worksheet.d.ts.map +1 -1
  155. package/dist/routers/worksheet.js +26 -2
  156. package/dist/routers/worksheet.js.map +1 -1
  157. package/dist/routes/aiStream.d.ts +2 -0
  158. package/dist/routes/aiStream.d.ts.map +1 -0
  159. package/dist/routes/aiStream.js +726 -0
  160. package/dist/routes/aiStream.js.map +1 -0
  161. package/dist/seedDatabase.d.ts +3 -1
  162. package/dist/seedDatabase.d.ts.map +1 -1
  163. package/dist/seedDatabase.js +6 -6
  164. package/dist/seedDatabase.js.map +1 -1
  165. package/dist/services/adminInstitution.d.ts +8 -8
  166. package/dist/services/adminRunJob.d.ts +4 -4
  167. package/dist/services/agenda.d.ts +18 -18
  168. package/dist/services/aiChatStream.d.ts +88 -0
  169. package/dist/services/aiChatStream.d.ts.map +1 -0
  170. package/dist/services/aiChatStream.js +254 -0
  171. package/dist/services/aiChatStream.js.map +1 -0
  172. package/dist/services/aiModifierTools.d.ts +16 -0
  173. package/dist/services/aiModifierTools.d.ts.map +1 -0
  174. package/dist/services/aiModifierTools.js +274 -0
  175. package/dist/services/aiModifierTools.js.map +1 -0
  176. package/dist/services/aiProposalTools.d.ts +32 -0
  177. package/dist/services/aiProposalTools.d.ts.map +1 -0
  178. package/dist/services/aiProposalTools.js +207 -0
  179. package/dist/services/aiProposalTools.js.map +1 -0
  180. package/dist/services/aiStream.d.ts +22 -0
  181. package/dist/services/aiStream.d.ts.map +1 -0
  182. package/dist/services/aiStream.js +142 -0
  183. package/dist/services/aiStream.js.map +1 -0
  184. package/dist/services/aiTools.d.ts +16 -0
  185. package/dist/services/aiTools.d.ts.map +1 -0
  186. package/dist/services/aiTools.js +271 -0
  187. package/dist/services/aiTools.js.map +1 -0
  188. package/dist/services/announcement.d.ts +42 -42
  189. package/dist/services/announcement.d.ts.map +1 -1
  190. package/dist/services/announcement.js +7 -2
  191. package/dist/services/announcement.js.map +1 -1
  192. package/dist/services/assignment.d.ts +355 -345
  193. package/dist/services/assignment.d.ts.map +1 -1
  194. package/dist/services/assignment.js +30 -9
  195. package/dist/services/assignment.js.map +1 -1
  196. package/dist/services/attendance.d.ts +10 -10
  197. package/dist/services/auth.d.ts +4 -2
  198. package/dist/services/auth.d.ts.map +1 -1
  199. package/dist/services/auth.js +13 -4
  200. package/dist/services/auth.js.map +1 -1
  201. package/dist/services/class.d.ts +146 -142
  202. package/dist/services/class.d.ts.map +1 -1
  203. package/dist/services/class.js +24 -4
  204. package/dist/services/class.js.map +1 -1
  205. package/dist/services/comment.d.ts +18 -18
  206. package/dist/services/conversation.d.ts +11 -11
  207. package/dist/services/department.d.ts +33 -33
  208. package/dist/services/discussion.d.ts +58 -58
  209. package/dist/services/discussion.d.ts.map +1 -1
  210. package/dist/services/discussion.js +15 -8
  211. package/dist/services/discussion.js.map +1 -1
  212. package/dist/services/discussionSummary.d.ts +21 -0
  213. package/dist/services/discussionSummary.d.ts.map +1 -0
  214. package/dist/services/discussionSummary.js +179 -0
  215. package/dist/services/discussionSummary.js.map +1 -0
  216. package/dist/services/embedding.d.ts +85 -0
  217. package/dist/services/embedding.d.ts.map +1 -0
  218. package/dist/services/embedding.js +372 -0
  219. package/dist/services/embedding.js.map +1 -0
  220. package/dist/services/event.d.ts +55 -53
  221. package/dist/services/event.d.ts.map +1 -1
  222. package/dist/services/file.d.ts +28 -28
  223. package/dist/services/folder.d.ts +36 -36
  224. package/dist/services/folder.d.ts.map +1 -1
  225. package/dist/services/folder.js +32 -2
  226. package/dist/services/folder.js.map +1 -1
  227. package/dist/services/labChat.d.ts +16 -16
  228. package/dist/services/marketing.d.ts +2 -15
  229. package/dist/services/marketing.d.ts.map +1 -1
  230. package/dist/services/marketing.js +5 -5
  231. package/dist/services/marketing.js.map +1 -1
  232. package/dist/services/message.d.ts +11 -11
  233. package/dist/services/message.d.ts.map +1 -1
  234. package/dist/services/message.js +2 -2
  235. package/dist/services/message.js.map +1 -1
  236. package/dist/services/newtonChat.d.ts +4 -4
  237. package/dist/services/notification.d.ts +5 -5
  238. package/dist/services/notification.d.ts.map +1 -1
  239. package/dist/services/openaiDocumentGen.d.ts +37 -0
  240. package/dist/services/openaiDocumentGen.d.ts.map +1 -0
  241. package/dist/services/openaiDocumentGen.js +262 -0
  242. package/dist/services/openaiDocumentGen.js.map +1 -0
  243. package/dist/services/permissions.d.ts +14 -14
  244. package/dist/services/school.d.ts +54 -50
  245. package/dist/services/school.d.ts.map +1 -1
  246. package/dist/services/search.d.ts +13 -1
  247. package/dist/services/search.d.ts.map +1 -1
  248. package/dist/services/search.js +145 -2
  249. package/dist/services/search.js.map +1 -1
  250. package/dist/services/section.d.ts +3 -3
  251. package/dist/services/tag.d.ts +4 -4
  252. package/dist/services/user.d.ts +4 -4
  253. package/dist/services/user.d.ts.map +1 -1
  254. package/dist/services/user.js +10 -15
  255. package/dist/services/user.js.map +1 -1
  256. package/dist/services/userPrivacy.d.ts +86 -0
  257. package/dist/services/userPrivacy.d.ts.map +1 -0
  258. package/dist/services/userPrivacy.js +184 -0
  259. package/dist/services/userPrivacy.js.map +1 -0
  260. package/dist/services/worksheet.d.ts +86 -57
  261. package/dist/services/worksheet.d.ts.map +1 -1
  262. package/dist/services/worksheet.js +57 -5
  263. package/dist/services/worksheet.js.map +1 -1
  264. package/dist/utils/email.d.ts.map +1 -1
  265. package/dist/utils/email.js +7 -3
  266. package/dist/utils/email.js.map +1 -1
  267. package/dist/utils/streamBroadcast.d.ts +11 -0
  268. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  269. package/dist/utils/streamBroadcast.js +32 -0
  270. package/dist/utils/streamBroadcast.js.map +1 -0
  271. package/dist/workers/connection.d.ts +15 -0
  272. package/dist/workers/connection.d.ts.map +1 -1
  273. package/dist/workers/connection.js +20 -2
  274. package/dist/workers/connection.js.map +1 -1
  275. package/dist/workers/index.js +4 -2
  276. package/dist/workers/index.js.map +1 -1
  277. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  278. package/dist/workers/processors/createRunJobWorker.js +31 -8
  279. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  280. package/dist/workers/processors/embedContent.d.ts +8 -0
  281. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  282. package/dist/workers/processors/embedContent.js +67 -0
  283. package/dist/workers/processors/embedContent.js.map +1 -0
  284. package/dist/workers/queues.d.ts +6 -0
  285. package/dist/workers/queues.d.ts.map +1 -1
  286. package/dist/workers/queues.js +5 -3
  287. package/dist/workers/queues.js.map +1 -1
  288. package/package.json +5 -1
  289. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  290. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  291. package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
  292. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  293. package/prisma/schema.prisma +44 -7
  294. package/src/index.ts +4 -0
  295. package/src/instrument.ts +1 -1
  296. package/src/lib/config/cors.ts +32 -9
  297. package/src/lib/config/env.ts +6 -0
  298. package/src/lib/googleCloudStorage.ts +21 -0
  299. package/src/lib/inferDocumentFormat.ts +58 -0
  300. package/src/lib/legal.ts +2 -0
  301. package/src/lib/prisma.ts +40 -1
  302. package/src/models/auth.ts +4 -0
  303. package/src/models/class.ts +3 -2
  304. package/src/models/marketing.ts +10 -2
  305. package/src/models/worksheet.ts +5 -2
  306. package/src/pipelines/aiLabChat.ts +245 -146
  307. package/src/pipelines/aiLabChatContract.ts +59 -69
  308. package/src/pipelines/aiNewtonChat.ts +46 -43
  309. package/src/pipelines/labChatPrompt.ts +115 -90
  310. package/src/routers/_app.ts +2 -0
  311. package/src/routers/auth.ts +13 -0
  312. package/src/routers/discussion.ts +7 -0
  313. package/src/routers/embedding.ts +164 -0
  314. package/src/routers/search.ts +10 -1
  315. package/src/routers/user.ts +26 -0
  316. package/src/routers/worksheet.ts +27 -0
  317. package/src/routes/aiStream.ts +827 -0
  318. package/src/seedDatabase.ts +4 -4
  319. package/src/services/aiChatStream.ts +341 -0
  320. package/src/services/aiModifierTools.ts +341 -0
  321. package/src/services/aiProposalTools.ts +274 -0
  322. package/src/services/aiStream.ts +175 -0
  323. package/src/services/aiTools.ts +290 -0
  324. package/src/services/announcement.ts +7 -0
  325. package/src/services/assignment.ts +31 -7
  326. package/src/services/auth.ts +14 -2
  327. package/src/services/class.ts +27 -2
  328. package/src/services/discussion.ts +22 -8
  329. package/src/services/discussionSummary.ts +226 -0
  330. package/src/services/embedding.ts +446 -0
  331. package/src/services/folder.ts +31 -0
  332. package/src/services/marketing.ts +3 -3
  333. package/src/services/message.ts +2 -2
  334. package/src/services/openaiDocumentGen.ts +319 -0
  335. package/src/services/search.ts +163 -1
  336. package/src/services/user.ts +9 -17
  337. package/src/services/userPrivacy.ts +196 -0
  338. package/src/services/worksheet.ts +77 -3
  339. package/src/utils/email.ts +5 -1
  340. package/src/utils/streamBroadcast.ts +48 -0
  341. package/src/workers/connection.ts +19 -0
  342. package/src/workers/index.ts +2 -0
  343. package/src/workers/processors/createRunJobWorker.ts +32 -6
  344. package/src/workers/processors/embedContent.ts +86 -0
  345. package/src/workers/queues.ts +5 -1
  346. package/tests/helpers/registerInput.ts +20 -0
  347. package/tests/integration/globalSetup.ts +10 -18
  348. package/tests/integration/routers/auth.test.ts +13 -18
  349. package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
  350. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  351. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  352. package/tests/unit/services/aiChatStream.test.ts +92 -0
  353. package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
  354. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  355. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  356. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  357. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  358. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  359. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  360. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  361. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  362. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  363. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  364. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  365. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  366. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  367. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  368. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  369. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  370. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  371. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  372. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  373. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  374. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  375. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * User privacy service — data export and account erasure (anonymization).
3
+ */
4
+ import { TRPCError } from "@trpc/server";
5
+ import { randomBytes } from "node:crypto";
6
+ import { hash } from "bcryptjs";
7
+ import { prisma } from "../lib/prisma.js";
8
+ import { deleteSessionsByUserId } from "../models/auth.js";
9
+
10
+ async function assertAccountCanBeErased(userId: string) {
11
+ const user = await prisma.user.findUnique({
12
+ where: { id: userId },
13
+ select: {
14
+ id: true,
15
+ teacherIn: {
16
+ select: {
17
+ id: true,
18
+ name: true,
19
+ _count: { select: { students: true } },
20
+ },
21
+ },
22
+ _count: {
23
+ select: {
24
+ assignments: true,
25
+ createdLabChats: true,
26
+ },
27
+ },
28
+ },
29
+ });
30
+
31
+ if (!user) {
32
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
33
+ }
34
+
35
+ const classesWithStudents = user.teacherIn.filter(
36
+ (cls) => cls._count.students > 0,
37
+ );
38
+ if (classesWithStudents.length > 0) {
39
+ throw new TRPCError({
40
+ code: "PRECONDITION_FAILED",
41
+ message:
42
+ "Delete or transfer your classes before deleting your account.",
43
+ });
44
+ }
45
+
46
+ if (user._count.assignments > 0) {
47
+ throw new TRPCError({
48
+ code: "PRECONDITION_FAILED",
49
+ message:
50
+ "Remove or reassign your assignments before deleting your account.",
51
+ });
52
+ }
53
+
54
+ if (user._count.createdLabChats > 0) {
55
+ throw new TRPCError({
56
+ code: "PRECONDITION_FAILED",
57
+ message: "Remove your AI lab chats before deleting your account.",
58
+ });
59
+ }
60
+ }
61
+
62
+ /** Export a portable snapshot of the authenticated user's personal data. */
63
+ export async function exportUserData(userId: string) {
64
+ const user = await prisma.user.findUnique({
65
+ where: { id: userId },
66
+ select: {
67
+ id: true,
68
+ username: true,
69
+ email: true,
70
+ verified: true,
71
+ termsAcceptedAt: true,
72
+ termsVersion: true,
73
+ profile: true,
74
+ teacherIn: {
75
+ select: { id: true, name: true, subject: true, section: true },
76
+ },
77
+ studentIn: {
78
+ select: { id: true, name: true, subject: true, section: true },
79
+ },
80
+ schoolMemberships: {
81
+ select: {
82
+ role: true,
83
+ status: true,
84
+ school: { select: { id: true, name: true } },
85
+ },
86
+ },
87
+ submissions: {
88
+ select: {
89
+ id: true,
90
+ createdAt: true,
91
+ submittedAt: true,
92
+ gradeReceived: true,
93
+ extendedResponse: true,
94
+ assignment: { select: { id: true, title: true, classId: true } },
95
+ },
96
+ },
97
+ events: {
98
+ select: {
99
+ id: true,
100
+ name: true,
101
+ startTime: true,
102
+ endTime: true,
103
+ location: true,
104
+ remarks: true,
105
+ },
106
+ },
107
+ sentMessages: {
108
+ select: {
109
+ id: true,
110
+ content: true,
111
+ createdAt: true,
112
+ conversationId: true,
113
+ },
114
+ take: 500,
115
+ orderBy: { createdAt: "desc" },
116
+ },
117
+ notificationsReceived: {
118
+ select: {
119
+ id: true,
120
+ title: true,
121
+ content: true,
122
+ read: true,
123
+ createdAt: true,
124
+ },
125
+ take: 200,
126
+ orderBy: { createdAt: "desc" },
127
+ },
128
+ },
129
+ });
130
+
131
+ if (!user) {
132
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
133
+ }
134
+
135
+ return {
136
+ exportedAt: new Date().toISOString(),
137
+ user,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Erase personal account data by anonymizing the user record and removing
143
+ * sessions and memberships. Educational records tied to classes (e.g.
144
+ * submissions) remain with an anonymized identity where required.
145
+ */
146
+ export async function deleteUserAccount(userId: string) {
147
+ await assertAccountCanBeErased(userId);
148
+ await deleteSessionsByUserId(userId);
149
+
150
+ const anonUsername = `deleted_${userId.slice(0, 8)}`;
151
+ const anonEmail = `deleted+${userId}@studious.invalid`;
152
+ const randomPassword = await hash(randomBytes(32).toString("hex"), 10);
153
+
154
+ await prisma.$transaction([
155
+ prisma.conversationMember.deleteMany({ where: { userId } }),
156
+ prisma.notification.deleteMany({
157
+ where: { OR: [{ senderId: userId }, { receiverId: userId }] },
158
+ }),
159
+ prisma.schoolMembership.deleteMany({ where: { userId } }),
160
+ prisma.permission.deleteMany({
161
+ where: { OR: [{ userId }, { grantedById: userId }] },
162
+ }),
163
+ prisma.user.update({
164
+ where: { id: userId },
165
+ data: {
166
+ teacherIn: { set: [] },
167
+ studentIn: { set: [] },
168
+ departments: { set: [] },
169
+ tags: { set: [] },
170
+ specificAssignments: { set: [] },
171
+ presentAttendance: { set: [] },
172
+ lateAttendance: { set: [] },
173
+ absentAttendance: { set: [] },
174
+ schoolId: null,
175
+ username: anonUsername,
176
+ email: anonEmail,
177
+ password: randomPassword,
178
+ verified: false,
179
+ termsAcceptedAt: null,
180
+ termsVersion: null,
181
+ profile: {
182
+ update: {
183
+ displayName: null,
184
+ bio: null,
185
+ location: null,
186
+ website: null,
187
+ profilePicture: null,
188
+ profilePictureThumbnail: null,
189
+ },
190
+ },
191
+ },
192
+ }),
193
+ ]);
194
+
195
+ return { success: true };
196
+ }
@@ -3,7 +3,7 @@
3
3
  * Integrates with gradeWorksheet pipeline for auto-grading.
4
4
  */
5
5
  import { TRPCError } from "@trpc/server";
6
- import { JobStatus, type WorksheetQuestionType } from "@prisma/client";
6
+ import { JobStatus, Prisma, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { getFileUrl } from "../lib/fileUpload.js";
9
9
  import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
@@ -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
  /**
@@ -316,6 +323,73 @@ export async function addQuestionToWorksheet(
316
323
  });
317
324
  }
318
325
 
326
+ /** A question's full editable state as captured before an AI modifier run. */
327
+ export interface WorksheetQuestionSnapshot {
328
+ id: string;
329
+ type: WorksheetQuestionType;
330
+ question: string;
331
+ answer: string;
332
+ options: unknown;
333
+ markScheme: unknown;
334
+ points: number;
335
+ order: number | null;
336
+ }
337
+
338
+ const toJsonInput = (value: unknown) =>
339
+ value === null || value === undefined
340
+ ? Prisma.JsonNull
341
+ : (value as Prisma.InputJsonValue);
342
+
343
+ /**
344
+ * Restore a worksheet's questions to a snapshot: deletes questions not in the
345
+ * snapshot, re-creates deleted ones (with their original ids), and reverts
346
+ * edited ones. Used by AI modifier undo.
347
+ */
348
+ export async function restoreWorksheetQuestions(
349
+ worksheetId: string,
350
+ userId: string,
351
+ questions: WorksheetQuestionSnapshot[]
352
+ ) {
353
+ const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
354
+ if (!worksheet) {
355
+ throw new TRPCError({
356
+ code: "FORBIDDEN",
357
+ message: "Only class teachers can modify worksheet questions",
358
+ });
359
+ }
360
+
361
+ await prisma.$transaction([
362
+ prisma.worksheetQuestion.deleteMany({
363
+ where: { worksheetId, id: { notIn: questions.map((q) => q.id) } },
364
+ }),
365
+ ...questions.map((q) =>
366
+ prisma.worksheetQuestion.upsert({
367
+ where: { id: q.id },
368
+ create: {
369
+ id: q.id,
370
+ worksheetId,
371
+ type: q.type,
372
+ question: q.question,
373
+ answer: q.answer,
374
+ options: toJsonInput(q.options),
375
+ markScheme: toJsonInput(q.markScheme),
376
+ points: q.points,
377
+ order: q.order,
378
+ },
379
+ update: {
380
+ type: q.type,
381
+ question: q.question,
382
+ answer: q.answer,
383
+ options: toJsonInput(q.options),
384
+ markScheme: toJsonInput(q.markScheme),
385
+ points: q.points,
386
+ order: q.order,
387
+ },
388
+ })
389
+ ),
390
+ ]);
391
+ }
392
+
319
393
  export async function reorderWorksheetQuestions(
320
394
  worksheetId: string,
321
395
  userId: string,
@@ -27,7 +27,11 @@ export const transport = nodemailer.createTransport({
27
27
  export const sendMail = async ({ from, to, subject, text }: sendMailProps) => {
28
28
  // Wrapper function for sending emails
29
29
  if (env.EMAIL_DRY_RUN == "true") {
30
- logger.info(`Email dry run enabled. Would have sent email to ${to} from ${from} with subject ${subject} and text ${text}`);
30
+ logger.info("Email dry run enabled. Would have sent email", {
31
+ to,
32
+ from,
33
+ subject,
34
+ });
31
35
  return;
32
36
  }
33
37
 
@@ -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,86 @@
1
+ /**
2
+ * Background worker for embedding content into pgvector.
3
+ * Handles indexing of files, assignments, worksheets, announcements,
4
+ * discussions, submissions, and syllabi.
5
+ */
6
+ import { createRunJobWorker } from './createRunJobWorker.js';
7
+ import {
8
+ indexAssignment,
9
+ indexWorksheet,
10
+ indexAnnouncement,
11
+ indexDiscussion,
12
+ indexSubmission,
13
+ indexSyllabus,
14
+ indexFile,
15
+ indexContent,
16
+ } from '../../services/embedding.js';
17
+ import { prisma } from '../../lib/prisma.js';
18
+ import { logger } from '../../utils/logger.js';
19
+ import type { EmbeddingSourceType } from '@prisma/client';
20
+
21
+ export const embedContentWorker = createRunJobWorker({
22
+ type: 'EMBED_CONTENT',
23
+ workerOptions: {
24
+ concurrency: 3,
25
+ limiter: { max: 10, duration: 60_000 },
26
+ },
27
+ run: async ({ classId, sourceType, sourceId }) => {
28
+ const typedSourceType = sourceType as EmbeddingSourceType;
29
+
30
+ switch (typedSourceType) {
31
+ case 'ASSIGNMENT':
32
+ await indexAssignment(classId, sourceId);
33
+ break;
34
+
35
+ case 'WORKSHEET':
36
+ await indexWorksheet(classId, sourceId);
37
+ break;
38
+
39
+ case 'ANNOUNCEMENT':
40
+ await indexAnnouncement(classId, sourceId);
41
+ break;
42
+
43
+ case 'FILE':
44
+ await indexFile(classId, sourceId);
45
+ break;
46
+
47
+ case 'DISCUSSION':
48
+ await indexDiscussion(classId, sourceId);
49
+ break;
50
+
51
+ case 'SUBMISSION':
52
+ await indexSubmission(classId, sourceId);
53
+ break;
54
+
55
+ case 'SYLLABUS':
56
+ await indexSyllabus(sourceId);
57
+ break;
58
+
59
+ case 'SECTION': {
60
+ const section = await prisma.section.findUnique({
61
+ where: { id: sourceId },
62
+ select: { name: true },
63
+ });
64
+
65
+ if (!section) {
66
+ return { skipped: true, reason: 'section_not_found' };
67
+ }
68
+
69
+ await indexContent({
70
+ classId,
71
+ sourceType: 'SECTION',
72
+ sourceId,
73
+ text: `Section: ${section.name}`,
74
+ metadata: { name: section.name },
75
+ });
76
+ break;
77
+ }
78
+
79
+ default:
80
+ logger.warn('Unknown embedding source type', { sourceType, sourceId });
81
+ return { skipped: true, reason: 'unknown_source_type' };
82
+ }
83
+
84
+ return { indexed: true, sourceType, sourceId };
85
+ },
86
+ });
@@ -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);
@@ -0,0 +1,20 @@
1
+ import { CURRENT_TERMS_VERSION } from "../../src/lib/legal.js";
2
+
3
+ export function registerInput(
4
+ overrides: {
5
+ username: string;
6
+ email: string;
7
+ password?: string;
8
+ confirmPassword?: string;
9
+ },
10
+ ) {
11
+ const password = overrides.password ?? "password_is_1234";
12
+ return {
13
+ username: overrides.username,
14
+ email: overrides.email,
15
+ password,
16
+ confirmPassword: overrides.confirmPassword ?? password,
17
+ agreeToTerms: true as const,
18
+ termsVersion: CURRENT_TERMS_VERSION,
19
+ };
20
+ }
@@ -10,6 +10,7 @@ import { createTRPCContext } from '../../src/trpc';
10
10
  import { clearDatabase } from '../../src/seedDatabase';
11
11
  import { execSync } from 'child_process';
12
12
  import { SessionType } from '@prisma/client';
13
+ import { registerInput } from '../helpers/registerInput.js';
13
14
 
14
15
  async function runSetup() {
15
16
  const ctx = await createTRPCContext({
@@ -20,26 +21,17 @@ async function runSetup() {
20
21
 
21
22
  execSync('npm run test:pre');
22
23
 
23
- const user1 = await caller.auth.register({
24
- username: 'testuser1',
25
- email: 'test@studious.sh',
26
- password: 'password_is_1234',
27
- confirmPassword: 'password_is_1234',
28
- });
24
+ const user1 = await caller.auth.register(
25
+ registerInput({ username: 'testuser1', email: 'test@studious.sh' }),
26
+ );
29
27
 
30
- const user2 = await caller.auth.register({
31
- username: 'testuser2',
32
- email: 'test2@studious.sh',
33
- password: 'password_is_1234',
34
- confirmPassword: 'password_is_1234',
35
- });
28
+ const user2 = await caller.auth.register(
29
+ registerInput({ username: 'testuser2', email: 'test2@studious.sh' }),
30
+ );
36
31
 
37
- const user3 = await caller.auth.register({
38
- username: 'testuser3',
39
- email: 'test3@studious.sh',
40
- password: 'password_is_1234',
41
- confirmPassword: 'password_is_1234',
42
- });
32
+ const user3 = await caller.auth.register(
33
+ registerInput({ username: 'testuser3', email: 'test3@studious.sh' }),
34
+ );
43
35
 
44
36
  const session1 = await prisma.session.findFirst({
45
37
  where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },