@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
@@ -0,0 +1,290 @@
1
+ /**
2
+ * AI Tool definitions and execution for agent tool calls.
3
+ * Provides RAG search, assignment lookup, worksheet creation, student progress,
4
+ * and web search tools.
5
+ */
6
+ import { jsonSchema, type ToolSet } from 'ai';
7
+ import OpenAI from 'openai';
8
+ import { searchEmbeddings } from './embedding.js';
9
+ import { prisma } from '../lib/prisma.js';
10
+ import { logger } from '../utils/logger.js';
11
+
12
+ export interface ToolCallResult {
13
+ toolName: string;
14
+ result: unknown;
15
+ }
16
+
17
+ /**
18
+ * Build the tool set for a given class context.
19
+ * Tools are scoped to a specific class for security.
20
+ */
21
+ export function aiTools(classId: string): ToolSet {
22
+ return {
23
+ search_materials: {
24
+ description: 'Search class materials, files, assignments, worksheets, and announcements using semantic similarity. Use this to find relevant context for answering questions.',
25
+ inputSchema: jsonSchema({
26
+ type: 'object',
27
+ properties: {
28
+ query: { type: 'string', description: 'The search query to find relevant materials' },
29
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
30
+ },
31
+ required: ['query'],
32
+ }),
33
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
34
+ try {
35
+ const results = await searchEmbeddings({ classId, query, limit });
36
+ return results.map((r) => ({
37
+ sourceType: r.sourceType,
38
+ sourceId: r.sourceId,
39
+ content: r.content.slice(0, 500),
40
+ similarity: Math.round(r.similarity * 100) / 100,
41
+ metadata: r.metadata,
42
+ }));
43
+ } catch (error) {
44
+ logger.error('search_materials tool failed', { error, classId, query });
45
+ return [];
46
+ }
47
+ },
48
+ },
49
+
50
+ search_assignments: {
51
+ description: 'Search existing assignments by title, instructions, or type. Returns assignment details including due dates and submission types.',
52
+ inputSchema: jsonSchema({
53
+ type: 'object',
54
+ properties: {
55
+ query: { type: 'string', description: 'Search query for assignments' },
56
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
57
+ },
58
+ required: ['query'],
59
+ }),
60
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
61
+ try {
62
+ const results = await searchEmbeddings({
63
+ classId,
64
+ query,
65
+ limit,
66
+ sourceTypes: ['ASSIGNMENT'],
67
+ });
68
+
69
+ const assignmentIds = results.map((r) => r.sourceId);
70
+ const assignments = await prisma.assignment.findMany({
71
+ where: { id: { in: assignmentIds } },
72
+ select: {
73
+ id: true,
74
+ title: true,
75
+ instructions: true,
76
+ type: true,
77
+ dueDate: true,
78
+ maxGrade: true,
79
+ section: { select: { name: true } },
80
+ },
81
+ });
82
+
83
+ return assignments.map((a) => ({
84
+ id: a.id,
85
+ title: a.title,
86
+ instructions: a.instructions?.slice(0, 200) ?? null,
87
+ type: a.type,
88
+ dueDate: a.dueDate.toISOString(),
89
+ maxGrade: a.maxGrade,
90
+ section: a.section?.name ?? null,
91
+ }));
92
+ } catch (error) {
93
+ logger.error('search_assignments tool failed', { error, classId, query });
94
+ return [];
95
+ }
96
+ },
97
+ },
98
+
99
+ get_student_progress: {
100
+ description: 'Get progress summary for a specific student in this class, including grades and submission status.',
101
+ inputSchema: jsonSchema({
102
+ type: 'object',
103
+ properties: {
104
+ studentId: { type: 'string', description: 'The student user ID' },
105
+ },
106
+ required: ['studentId'],
107
+ }),
108
+ execute: async ({ studentId }: { studentId: string }) => {
109
+ try {
110
+ const submissions = await prisma.submission.findMany({
111
+ where: {
112
+ studentId,
113
+ assignment: { classId },
114
+ },
115
+ select: {
116
+ id: true,
117
+ gradeReceived: true,
118
+ submittedAt: true,
119
+ assignment: {
120
+ select: {
121
+ title: true,
122
+ maxGrade: true,
123
+ type: true,
124
+ },
125
+ },
126
+ },
127
+ orderBy: { submittedAt: 'desc' },
128
+ take: 20,
129
+ });
130
+
131
+ const totalAssignments = await prisma.assignment.count({
132
+ where: { classId },
133
+ });
134
+
135
+ const submitted = submissions.filter((s) => s.submittedAt);
136
+ const graded = submissions.filter((s) => s.gradeReceived !== null);
137
+ const avgGrade = graded.length > 0
138
+ ? graded.reduce((sum, s) => sum + (s.gradeReceived || 0), 0) / graded.length
139
+ : null;
140
+
141
+ return {
142
+ studentId,
143
+ totalAssignments,
144
+ submittedCount: submitted.length,
145
+ gradedCount: graded.length,
146
+ averageGrade: avgGrade ? Math.round(avgGrade * 10) / 10 : null,
147
+ recentSubmissions: submissions.slice(0, 5).map((s) => ({
148
+ assignment: s.assignment.title,
149
+ gradeReceived: s.gradeReceived,
150
+ maxGrade: s.assignment.maxGrade,
151
+ submittedAt: s.submittedAt?.toISOString() ?? null,
152
+ })),
153
+ };
154
+ } catch (error) {
155
+ logger.error('get_student_progress tool failed', { error, classId, studentId });
156
+ return { error: 'Failed to fetch student progress' };
157
+ }
158
+ },
159
+ },
160
+
161
+ get_worksheet_details: {
162
+ description: 'Get full details of a worksheet including all questions, answers, and mark schemes.',
163
+ inputSchema: jsonSchema({
164
+ type: 'object',
165
+ properties: {
166
+ worksheetId: { type: 'string', description: 'The worksheet ID to look up' },
167
+ },
168
+ required: ['worksheetId'],
169
+ }),
170
+ execute: async ({ worksheetId }: { worksheetId: string }) => {
171
+ try {
172
+ const worksheet = await prisma.worksheet.findUnique({
173
+ where: { id: worksheetId, classId },
174
+ include: {
175
+ questions: {
176
+ orderBy: { order: 'asc' },
177
+ },
178
+ },
179
+ });
180
+
181
+ if (!worksheet) {
182
+ return { error: 'Worksheet not found' };
183
+ }
184
+
185
+ return {
186
+ id: worksheet.id,
187
+ name: worksheet.name,
188
+ questions: worksheet.questions.map((q) => ({
189
+ id: q.id,
190
+ type: q.type,
191
+ question: q.question,
192
+ answer: q.answer,
193
+ options: q.options,
194
+ points: q.points,
195
+ order: q.order,
196
+ })),
197
+ };
198
+ } catch (error) {
199
+ logger.error('get_worksheet_details tool failed', { error, classId, worksheetId });
200
+ return { error: 'Failed to fetch worksheet' };
201
+ }
202
+ },
203
+ },
204
+
205
+ get_mark_scheme: {
206
+ description: 'Get a mark scheme (rubric) by ID, including criteria and point allocations.',
207
+ inputSchema: jsonSchema({
208
+ type: 'object',
209
+ properties: {
210
+ markSchemeId: { type: 'string', description: 'The mark scheme ID' },
211
+ },
212
+ required: ['markSchemeId'],
213
+ }),
214
+ execute: async ({ markSchemeId }: { markSchemeId: string }) => {
215
+ try {
216
+ const markScheme = await prisma.markScheme.findUnique({
217
+ where: { id: markSchemeId },
218
+ select: { id: true, name: true, structured: true },
219
+ });
220
+
221
+ if (!markScheme) {
222
+ return { error: 'Mark scheme not found' };
223
+ }
224
+
225
+ let parsed: unknown = {};
226
+ try { parsed = JSON.parse(markScheme.structured || '{}'); } catch { /* noop */ }
227
+
228
+ return {
229
+ id: markScheme.id,
230
+ name: markScheme.name,
231
+ structured: parsed,
232
+ };
233
+ } catch (error) {
234
+ logger.error('get_mark_scheme tool failed', { error, classId, markSchemeId });
235
+ return { error: 'Failed to fetch mark scheme' };
236
+ }
237
+ },
238
+ },
239
+
240
+ web_search: {
241
+ description: 'Search the web for current information on a topic. Use this when you need up-to-date facts, definitions, explanations, or context beyond the class materials.',
242
+ inputSchema: jsonSchema({
243
+ type: 'object',
244
+ properties: {
245
+ query: { type: 'string', description: 'The search query to look up on the web' },
246
+ },
247
+ required: ['query'],
248
+ }),
249
+ execute: async ({ query }: { query: string }) => {
250
+ try {
251
+ const client = new OpenAI();
252
+ const response = await client.responses.create({
253
+ model: 'gpt-4o-mini',
254
+ tools: [{ type: 'web_search_preview' }],
255
+ input: query,
256
+ });
257
+
258
+ const messageOutput = response.output.find(
259
+ (item) => item.type === 'message',
260
+ );
261
+ if (!messageOutput || messageOutput.type !== 'message') {
262
+ return { summary: 'No results found.', sources: [] };
263
+ }
264
+
265
+ const textContent = messageOutput.content.find(
266
+ (c) => c.type === 'output_text',
267
+ );
268
+ if (!textContent || textContent.type !== 'output_text') {
269
+ return { summary: 'No results found.', sources: [] };
270
+ }
271
+
272
+ const sources = textContent.annotations
273
+ .filter(
274
+ (a): a is Extract<typeof a, { type: 'url_citation' }> =>
275
+ a.type === 'url_citation',
276
+ )
277
+ .map((a) => ({ url: a.url, title: a.title }));
278
+
279
+ return {
280
+ summary: textContent.text.slice(0, 2000),
281
+ sources: sources.slice(0, 5),
282
+ };
283
+ } catch (error) {
284
+ logger.error('web_search tool failed', { error, query });
285
+ return { summary: 'Web search failed. Please try again.', sources: [] };
286
+ }
287
+ },
288
+ },
289
+ };
290
+ }
@@ -16,6 +16,7 @@ import { createDirectUploadFiles, type DirectUploadFile } from "../lib/fileUploa
16
16
  import { deleteFile } from "../lib/googleCloudStorage.js";
17
17
  import { sendToMultiple } from "./notification.js";
18
18
  import { logger } from "../utils/logger.js";
19
+ import { enqueueEmbedding } from "./embedding.js";
19
20
 
20
21
  export async function getAllAnnouncements(classId: string) {
21
22
  const announcements = await findAnnouncementsByClassId(classId);
@@ -127,6 +128,9 @@ export async function createAnnouncementRecord(
127
128
  logger.error("Failed to send announcement notifications:", error);
128
129
  });
129
130
 
131
+ // Auto-index announcement for RAG search
132
+ enqueueEmbedding(classId, 'ANNOUNCEMENT', announcement.id);
133
+
130
134
  return {
131
135
  announcement: announcementWithAttachments || announcement,
132
136
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -263,6 +267,9 @@ export async function updateAnnouncementRecord(
263
267
  },
264
268
  });
265
269
 
270
+ // Re-index announcement for RAG search after update
271
+ enqueueEmbedding(input.classId, 'ANNOUNCEMENT', input.id);
272
+
266
273
  return {
267
274
  announcement: updatedAnnouncement,
268
275
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -27,6 +27,7 @@ import { sendToMultiple } from "./notification.js";
27
27
  import { enqueueRunJob } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
+ import { enqueueEmbedding } from "./embedding.js";
30
31
 
31
32
  export async function assignmentExists(id: string) {
32
33
  const assignment = await findAssignmentById(id);
@@ -311,13 +312,18 @@ export async function createAssignmentRecord(
311
312
  }
312
313
  await Promise.all(fileOperations);
313
314
 
314
- sendToMultiple({
315
- receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
316
- title: `New assignment in ${classData.name}`,
317
- content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
318
- }).catch((error) => {
319
- logger.error("Failed to send assignment notifications:", error);
320
- });
315
+ if (!inProgress) {
316
+ sendToMultiple({
317
+ receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
318
+ title: `New assignment in ${classData.name}`,
319
+ content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
320
+ }).catch((error) => {
321
+ logger.error("Failed to send assignment notifications:", error);
322
+ });
323
+ }
324
+
325
+ // Auto-index assignment for RAG search
326
+ enqueueEmbedding(classId, 'ASSIGNMENT', assignment.id);
321
327
 
322
328
  return assignment;
323
329
  }
@@ -425,12 +431,13 @@ export async function updateAssignmentRecord(
425
431
  });
426
432
  }
427
433
 
428
- const submissionData =
429
- input.studentIds?.length
430
- ? input.studentIds.map((studentId) => ({ student: { connect: { id: studentId } } }))
431
- : classData?.students.map((student) => ({
432
- student: { connect: { id: student.id } },
433
- }));
434
+ const submissionData = input.studentIds
435
+ ? (input.studentIds.length > 0
436
+ ? input.studentIds.map((studentId) => ({ student: { connect: { id: studentId } } }))
437
+ : classData?.students.map((student) => ({
438
+ student: { connect: { id: student.id } },
439
+ })))
440
+ : undefined;
434
441
 
435
442
  if (input.removedAttachments?.length) {
436
443
  const filesToDelete = assignment.attachments.filter((f) =>
@@ -481,7 +488,7 @@ export async function updateAssignmentRecord(
481
488
  connect: input.studentIds.map((id) => ({ id })),
482
489
  },
483
490
  }),
484
- ...(submissionData && {
491
+ ...(input.studentIds && submissionData && {
485
492
  submissions: {
486
493
  deleteMany: {},
487
494
  create: submissionData,
@@ -590,6 +597,21 @@ export async function updateAssignmentRecord(
590
597
  .catch((error) => logger.error("Failed to update max grade from rubric:", error));
591
598
  }
592
599
 
600
+ // Re-index assignment for RAG search after update
601
+ enqueueEmbedding(input.classId, 'ASSIGNMENT', input.id);
602
+ if (assignment.inProgress && input.inProgress === false && classData) {
603
+ const receiverIds = input.studentIds?.length
604
+ ? input.studentIds
605
+ : classData.students.map((s) => s.id);
606
+ sendToMultiple({
607
+ receiverIds,
608
+ title: `New assignment in ${classData.name}`,
609
+ content: `"${updatedAssignment.title}" is due ${new Date(updatedAssignment.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classData.id}/assignments/${updatedAssignment.id})`,
610
+ }).catch((error) => {
611
+ logger.error("Failed to send assignment publish notifications:", error);
612
+ });
613
+ }
614
+
593
615
  return updatedAssignment;
594
616
  }
595
617
 
@@ -27,6 +27,8 @@ import {
27
27
  } from "../models/class.js";
28
28
  import { Class, Folder } from "@prisma/client";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
+ import { sendToMultiple } from "./notification.js";
31
+ import { logger } from "../utils/logger.js";
30
32
 
31
33
  async function requireActiveSchoolMembers(schoolId: string, userIds: string[]) {
32
34
  const uniqueUserIds = [...new Set(userIds)];
@@ -85,7 +87,16 @@ export async function getAllClasses(userId: string) {
85
87
  section: cls.section,
86
88
  subject: cls.subject,
87
89
  dueToday: cls.assignments,
88
- assignments: cls.assignments,
90
+ assignments: cls.assignments.map((assignment) => ({
91
+ ...assignment,
92
+ late: new Date(assignment.dueDate) < new Date(),
93
+ submitted: assignment.submissions.find(
94
+ (s) => s.studentId === userId,
95
+ )?.submitted ?? false,
96
+ returned: assignment.submissions.find(
97
+ (s) => s.studentId === userId,
98
+ )?.returned ?? false,
99
+ })),
89
100
  members: [...cls.students, ...cls.teachers],
90
101
  color: cls.color,
91
102
  schoolId: cls.schoolId ?? null,
@@ -739,7 +750,7 @@ export async function publishLabDraft(
739
750
  });
740
751
  }
741
752
 
742
- return prisma.assignment.update({
753
+ const published = await prisma.assignment.update({
743
754
  where: { id: draftId },
744
755
  data: {
745
756
  inProgress: false,
@@ -750,6 +761,23 @@ export async function publishLabDraft(
750
761
  modifiedAt: new Date(),
751
762
  },
752
763
  });
764
+
765
+ const classData = await prisma.class.findUnique({
766
+ where: { id: classId },
767
+ select: { name: true, students: { select: { id: true } } },
768
+ });
769
+
770
+ if (classData) {
771
+ sendToMultiple({
772
+ receiverIds: classData.students.map((s) => s.id),
773
+ title: `New assignment in ${classData.name}`,
774
+ content: `"${published.title}" is due ${new Date(published.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${published.id})`,
775
+ }).catch((error) => {
776
+ logger.error("Failed to send lab draft publish notifications:", error);
777
+ });
778
+ }
779
+
780
+ return published;
753
781
  }
754
782
 
755
783
  export async function getFiles(classId: string) {
@@ -158,6 +158,28 @@ export async function deleteComment(userId: string, id: string) {
158
158
  return { success: true };
159
159
  }
160
160
 
161
+ /** Pin or unpin a comment. Only teachers can pin comments. */
162
+ export async function pinComment(userId: string, commentId: string, isPinned: boolean) {
163
+ const comment = await requireCommentAccess(commentId, userId);
164
+ if (comment.parentCommentId) {
165
+ throw new TRPCError({
166
+ code: "BAD_REQUEST",
167
+ message: "Only top-level comments can be pinned",
168
+ });
169
+ }
170
+ if (!isClassTeacher(comment, userId)) {
171
+ throw new TRPCError({
172
+ code: "FORBIDDEN",
173
+ message: "Only teachers can pin comments",
174
+ });
175
+ }
176
+ const updated = await prisma.comment.update({
177
+ where: { id: commentId },
178
+ data: { isPinned },
179
+ });
180
+ return updated;
181
+ }
182
+
161
183
  /** Add or update a reaction (emoji) on a comment. */
162
184
  export async function addReaction(
163
185
  userId: string,
@@ -15,6 +15,7 @@ import {
15
15
  findConversationByIdAndMember,
16
16
  createConversationMember,
17
17
  hideConversationMember,
18
+ unhideConversationMember,
18
19
  deleteConversationMember,
19
20
  } from "../models/conversation.js";
20
21
 
@@ -160,10 +161,14 @@ export async function createConversationRecord(
160
161
  });
161
162
 
162
163
  if (existingDM) {
163
- throw new TRPCError({
164
- code: "BAD_REQUEST",
165
- message: `A conversation with ${targetUser.username} already exists`,
166
- });
164
+ const userMembership = existingDM.members.find(
165
+ (m) => m.userId === userId
166
+ );
167
+ if (userMembership) {
168
+ await unhideConversationMember(userId, existingDM.id);
169
+ }
170
+ const conversation = await findConversationByIdAndMember(existingDM.id, userId);
171
+ return conversation!;
167
172
  }
168
173
  }
169
174
 
@@ -1,4 +1,5 @@
1
1
  import type { DiscussionType, Prisma, ReactionType } from "@prisma/client";
2
+ import type { AppTransactionClient } from "../lib/prisma.js";
2
3
  import { TRPCError } from "@trpc/server";
3
4
  import { prisma } from "../lib/prisma.js";
4
5
  import { discussionChannel, pusher } from "../lib/pusher.js";
@@ -225,7 +226,7 @@ async function getDiscussionForUser(discussionId: string, userId: string) {
225
226
  }
226
227
 
227
228
  async function claimDiscussionAttachments(
228
- tx: Prisma.TransactionClient,
229
+ tx: AppTransactionClient,
229
230
  userId: string,
230
231
  discussionId: string,
231
232
  attachmentIds: string[] = []
@@ -242,7 +243,7 @@ async function claimDiscussionAttachments(
242
243
  }
243
244
 
244
245
  async function claimReplyAttachments(
245
- tx: Prisma.TransactionClient,
246
+ tx: AppTransactionClient,
246
247
  userId: string,
247
248
  replyId: string,
248
249
  attachmentIds: string[] = []
@@ -674,28 +675,28 @@ export async function toggleDiscussionReaction(userId: string, input: { discussi
674
675
 
675
676
  export async function listDiscussionTags(userId: string, classId: string) {
676
677
  await getDiscussionClassAccess(classId, userId);
677
- return prisma.discussionTag.findMany({ where: { classId }, orderBy: { name: "asc" } });
678
+ return prisma.classTag.findMany({ where: { classId }, orderBy: { name: "asc" } });
678
679
  }
679
680
 
680
681
  export async function createDiscussionTag(userId: string, input: { classId: string; name: string; color: string }) {
681
682
  const isTeacher = await prisma.class.findFirst({ where: { id: input.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
682
683
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
683
- return prisma.discussionTag.create({ data: { classId: input.classId, name: input.name.trim(), color: input.color } });
684
+ return prisma.classTag.create({ data: { classId: input.classId, name: input.name.trim(), color: input.color } });
684
685
  }
685
686
 
686
687
  export async function updateDiscussionTag(userId: string, input: { tagId: string; name?: string; color?: string }) {
687
- const tag = await prisma.discussionTag.findUnique({ where: { id: input.tagId }, select: { classId: true } });
688
+ const tag = await prisma.classTag.findUnique({ where: { id: input.tagId }, select: { classId: true } });
688
689
  if (!tag) throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
689
690
  const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
690
691
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
691
- return prisma.discussionTag.update({ where: { id: input.tagId }, data: { name: input.name?.trim(), color: input.color } });
692
+ return prisma.classTag.update({ where: { id: input.tagId }, data: { name: input.name?.trim(), color: input.color } });
692
693
  }
693
694
 
694
695
  export async function deleteDiscussionTag(userId: string, tagId: string) {
695
- const tag = await prisma.discussionTag.findUnique({ where: { id: tagId }, select: { classId: true } });
696
+ const tag = await prisma.classTag.findUnique({ where: { id: tagId }, select: { classId: true } });
696
697
  if (!tag) throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
697
698
  const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
698
699
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
699
- await prisma.discussionTag.delete({ where: { id: tagId } });
700
+ await prisma.classTag.delete({ where: { id: tagId } });
700
701
  return { success: true };
701
702
  }