@studious-lms/server 1.13.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 (306) 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 -198
  32. package/dist/models/class.d.ts.map +1 -1
  33. package/dist/models/class.js +2 -2
  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 +267 -115
  38. package/dist/models/comment.d.ts.map +1 -1
  39. package/dist/models/conversation.d.ts +199 -73
  40. package/dist/models/conversation.d.ts.map +1 -1
  41. package/dist/models/department.d.ts +196 -70
  42. package/dist/models/department.d.ts.map +1 -1
  43. package/dist/models/event.d.ts +287 -125
  44. package/dist/models/event.d.ts.map +1 -1
  45. package/dist/models/file.d.ts +389 -263
  46. package/dist/models/file.d.ts.map +1 -1
  47. package/dist/models/folder.d.ts +433 -181
  48. package/dist/models/folder.d.ts.map +1 -1
  49. package/dist/models/labChat.d.ts +209 -83
  50. package/dist/models/labChat.d.ts.map +1 -1
  51. package/dist/models/marketing.d.ts +80 -26
  52. package/dist/models/marketing.d.ts.map +1 -1
  53. package/dist/models/message.d.ts +95 -41
  54. package/dist/models/message.d.ts.map +1 -1
  55. package/dist/models/newtonChat.d.ts +83 -29
  56. package/dist/models/newtonChat.d.ts.map +1 -1
  57. package/dist/models/notification.d.ts +61 -25
  58. package/dist/models/notification.d.ts.map +1 -1
  59. package/dist/models/school.d.ts +349 -133
  60. package/dist/models/school.d.ts.map +1 -1
  61. package/dist/models/section.d.ts +216 -72
  62. package/dist/models/section.d.ts.map +1 -1
  63. package/dist/models/tag.d.ts +156 -48
  64. package/dist/models/tag.d.ts.map +1 -1
  65. package/dist/models/user.d.ts +82 -28
  66. package/dist/models/user.d.ts.map +1 -1
  67. package/dist/models/worksheet.d.ts +1020 -411
  68. package/dist/models/worksheet.d.ts.map +1 -1
  69. package/dist/models/worksheet.js +8 -2
  70. package/dist/models/worksheet.js.map +1 -1
  71. package/dist/pipelines/aiLabChat.d.ts +0 -8
  72. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  73. package/dist/pipelines/aiLabChat.js +50 -67
  74. package/dist/pipelines/aiLabChat.js.map +1 -1
  75. package/dist/pipelines/aiLabChatContract.d.ts +393 -388
  76. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  77. package/dist/pipelines/aiLabChatContract.js +63 -69
  78. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +41 -41
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/labChatPrompt.d.ts +10 -8
  84. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  85. package/dist/pipelines/labChatPrompt.js +47 -67
  86. package/dist/pipelines/labChatPrompt.js.map +1 -1
  87. package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
  88. package/dist/routers/_app.d.ts +2203 -1957
  89. package/dist/routers/_app.d.ts.map +1 -1
  90. package/dist/routers/_app.js +4 -2
  91. package/dist/routers/_app.js.map +1 -1
  92. package/dist/routers/admin.d.ts +13 -13
  93. package/dist/routers/agenda.d.ts +18 -18
  94. package/dist/routers/announcement.d.ts +32 -32
  95. package/dist/routers/assignment.d.ts +335 -335
  96. package/dist/routers/attendance.d.ts +10 -10
  97. package/dist/routers/auth.d.ts +2 -2
  98. package/dist/routers/class.d.ts +147 -147
  99. package/dist/routers/comment.d.ts +22 -22
  100. package/dist/routers/conversation.d.ts +17 -17
  101. package/dist/routers/department.d.ts +34 -34
  102. package/dist/routers/discussion.d.ts +65 -53
  103. package/dist/routers/discussion.d.ts.map +1 -1
  104. package/dist/routers/discussion.js +6 -2
  105. package/dist/routers/discussion.js.map +1 -1
  106. package/dist/routers/embedding.d.ts +69 -0
  107. package/dist/routers/embedding.d.ts.map +1 -0
  108. package/dist/routers/embedding.js +143 -0
  109. package/dist/routers/embedding.js.map +1 -0
  110. package/dist/routers/event.d.ts +39 -39
  111. package/dist/routers/file.d.ts +12 -12
  112. package/dist/routers/folder.d.ts +38 -38
  113. package/dist/routers/labChat.d.ts +19 -19
  114. package/dist/routers/marketing.d.ts +2 -2
  115. package/dist/routers/message.d.ts +13 -13
  116. package/dist/routers/newtonChat.d.ts +4 -4
  117. package/dist/routers/notifications.d.ts +9 -9
  118. package/dist/routers/permissions.d.ts +14 -14
  119. package/dist/routers/school.d.ts +55 -55
  120. package/dist/routers/search.d.ts +22 -1
  121. package/dist/routers/search.d.ts.map +1 -1
  122. package/dist/routers/search.js +9 -3
  123. package/dist/routers/search.js.map +1 -1
  124. package/dist/routers/section.d.ts +4 -4
  125. package/dist/routers/studentProgress.d.ts +1 -1
  126. package/dist/routers/tag.d.ts +7 -7
  127. package/dist/routers/user.d.ts +10 -10
  128. package/dist/routers/worksheet.d.ts +95 -57
  129. package/dist/routers/worksheet.d.ts.map +1 -1
  130. package/dist/routers/worksheet.js +26 -2
  131. package/dist/routers/worksheet.js.map +1 -1
  132. package/dist/routes/aiStream.d.ts +2 -0
  133. package/dist/routes/aiStream.d.ts.map +1 -0
  134. package/dist/routes/aiStream.js +696 -0
  135. package/dist/routes/aiStream.js.map +1 -0
  136. package/dist/seedDatabase.d.ts +2 -2
  137. package/dist/seedDatabase.js +6 -6
  138. package/dist/seedDatabase.js.map +1 -1
  139. package/dist/services/adminInstitution.d.ts +8 -8
  140. package/dist/services/adminRunJob.d.ts +4 -4
  141. package/dist/services/agenda.d.ts +18 -18
  142. package/dist/services/aiChatStream.d.ts +68 -0
  143. package/dist/services/aiChatStream.d.ts.map +1 -0
  144. package/dist/services/aiChatStream.js +157 -0
  145. package/dist/services/aiChatStream.js.map +1 -0
  146. package/dist/services/aiModifierTools.d.ts +32 -0
  147. package/dist/services/aiModifierTools.d.ts.map +1 -0
  148. package/dist/services/aiModifierTools.js +165 -0
  149. package/dist/services/aiModifierTools.js.map +1 -0
  150. package/dist/services/aiProposalTools.d.ts +32 -0
  151. package/dist/services/aiProposalTools.d.ts.map +1 -0
  152. package/dist/services/aiProposalTools.js +207 -0
  153. package/dist/services/aiProposalTools.js.map +1 -0
  154. package/dist/services/aiStream.d.ts +22 -0
  155. package/dist/services/aiStream.d.ts.map +1 -0
  156. package/dist/services/aiStream.js +142 -0
  157. package/dist/services/aiStream.js.map +1 -0
  158. package/dist/services/aiTools.d.ts +16 -0
  159. package/dist/services/aiTools.d.ts.map +1 -0
  160. package/dist/services/aiTools.js +271 -0
  161. package/dist/services/aiTools.js.map +1 -0
  162. package/dist/services/announcement.d.ts +12 -12
  163. package/dist/services/announcement.d.ts.map +1 -1
  164. package/dist/services/announcement.js +7 -2
  165. package/dist/services/announcement.js.map +1 -1
  166. package/dist/services/assignment.d.ts +293 -293
  167. package/dist/services/assignment.d.ts.map +1 -1
  168. package/dist/services/assignment.js +28 -9
  169. package/dist/services/assignment.js.map +1 -1
  170. package/dist/services/attendance.d.ts +10 -10
  171. package/dist/services/auth.d.ts +2 -2
  172. package/dist/services/class.d.ts +138 -138
  173. package/dist/services/class.d.ts.map +1 -1
  174. package/dist/services/class.js +19 -3
  175. package/dist/services/class.js.map +1 -1
  176. package/dist/services/comment.d.ts +22 -22
  177. package/dist/services/conversation.d.ts +17 -17
  178. package/dist/services/department.d.ts +33 -33
  179. package/dist/services/discussion.d.ts +58 -58
  180. package/dist/services/discussion.d.ts.map +1 -1
  181. package/dist/services/discussion.js +8 -8
  182. package/dist/services/discussion.js.map +1 -1
  183. package/dist/services/discussionSummary.d.ts +21 -0
  184. package/dist/services/discussionSummary.d.ts.map +1 -0
  185. package/dist/services/discussionSummary.js +179 -0
  186. package/dist/services/discussionSummary.js.map +1 -0
  187. package/dist/services/embedding.d.ts +67 -0
  188. package/dist/services/embedding.d.ts.map +1 -0
  189. package/dist/services/embedding.js +226 -0
  190. package/dist/services/embedding.js.map +1 -0
  191. package/dist/services/event.d.ts +37 -37
  192. package/dist/services/file.d.ts +24 -24
  193. package/dist/services/folder.d.ts +36 -36
  194. package/dist/services/labChat.d.ts +19 -19
  195. package/dist/services/marketing.d.ts +2 -2
  196. package/dist/services/message.d.ts +11 -11
  197. package/dist/services/message.d.ts.map +1 -1
  198. package/dist/services/message.js +2 -2
  199. package/dist/services/message.js.map +1 -1
  200. package/dist/services/newtonChat.d.ts +4 -4
  201. package/dist/services/notification.d.ts +9 -9
  202. package/dist/services/notification.d.ts.map +1 -1
  203. package/dist/services/permissions.d.ts +14 -14
  204. package/dist/services/school.d.ts +50 -50
  205. package/dist/services/search.d.ts +12 -0
  206. package/dist/services/search.d.ts.map +1 -1
  207. package/dist/services/search.js +120 -2
  208. package/dist/services/search.js.map +1 -1
  209. package/dist/services/section.d.ts +3 -3
  210. package/dist/services/tag.d.ts +4 -4
  211. package/dist/services/user.d.ts +4 -4
  212. package/dist/services/user.d.ts.map +1 -1
  213. package/dist/services/user.js +10 -15
  214. package/dist/services/user.js.map +1 -1
  215. package/dist/services/worksheet.d.ts +60 -48
  216. package/dist/services/worksheet.d.ts.map +1 -1
  217. package/dist/services/worksheet.js +11 -4
  218. package/dist/services/worksheet.js.map +1 -1
  219. package/dist/utils/streamBroadcast.d.ts +11 -0
  220. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  221. package/dist/utils/streamBroadcast.js +32 -0
  222. package/dist/utils/streamBroadcast.js.map +1 -0
  223. package/dist/workers/connection.d.ts +15 -0
  224. package/dist/workers/connection.d.ts.map +1 -1
  225. package/dist/workers/connection.js +20 -2
  226. package/dist/workers/connection.js.map +1 -1
  227. package/dist/workers/index.js +4 -2
  228. package/dist/workers/index.js.map +1 -1
  229. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  230. package/dist/workers/processors/createRunJobWorker.js +31 -8
  231. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  232. package/dist/workers/processors/embedContent.d.ts +8 -0
  233. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  234. package/dist/workers/processors/embedContent.js +76 -0
  235. package/dist/workers/processors/embedContent.js.map +1 -0
  236. package/dist/workers/queues.d.ts +6 -0
  237. package/dist/workers/queues.d.ts.map +1 -1
  238. package/dist/workers/queues.js +5 -3
  239. package/dist/workers/queues.js.map +1 -1
  240. package/package.json +5 -1
  241. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  242. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  243. package/prisma/schema.prisma +38 -7
  244. package/src/index.ts +4 -0
  245. package/src/lib/config/cors.ts +32 -9
  246. package/src/lib/config/env.ts +6 -0
  247. package/src/lib/googleCloudStorage.ts +21 -0
  248. package/src/lib/prisma.ts +40 -1
  249. package/src/models/class.ts +3 -2
  250. package/src/models/worksheet.ts +5 -2
  251. package/src/pipelines/aiLabChat.ts +54 -79
  252. package/src/pipelines/aiLabChatContract.ts +69 -69
  253. package/src/pipelines/aiNewtonChat.ts +46 -43
  254. package/src/pipelines/labChatPrompt.ts +63 -88
  255. package/src/routers/_app.ts +2 -0
  256. package/src/routers/discussion.ts +7 -0
  257. package/src/routers/embedding.ts +159 -0
  258. package/src/routers/search.ts +10 -1
  259. package/src/routers/worksheet.ts +27 -0
  260. package/src/routes/aiStream.ts +789 -0
  261. package/src/seedDatabase.ts +4 -4
  262. package/src/services/aiChatStream.ts +219 -0
  263. package/src/services/aiModifierTools.ts +188 -0
  264. package/src/services/aiProposalTools.ts +272 -0
  265. package/src/services/aiStream.ts +175 -0
  266. package/src/services/aiTools.ts +290 -0
  267. package/src/services/announcement.ts +7 -0
  268. package/src/services/assignment.ts +28 -7
  269. package/src/services/class.ts +20 -1
  270. package/src/services/discussion.ts +9 -8
  271. package/src/services/discussionSummary.ts +226 -0
  272. package/src/services/embedding.ts +285 -0
  273. package/src/services/message.ts +2 -2
  274. package/src/services/search.ts +138 -0
  275. package/src/services/user.ts +9 -17
  276. package/src/services/worksheet.ts +9 -2
  277. package/src/utils/streamBroadcast.ts +48 -0
  278. package/src/workers/connection.ts +19 -0
  279. package/src/workers/index.ts +2 -0
  280. package/src/workers/processors/createRunJobWorker.ts +32 -6
  281. package/src/workers/processors/embedContent.ts +90 -0
  282. package/src/workers/queues.ts +5 -1
  283. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  284. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  285. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  286. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  287. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  288. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  289. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  290. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  291. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  292. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  293. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  294. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  295. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  296. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  297. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  298. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  299. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  300. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  301. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  302. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  303. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  304. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  305. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  306. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,13 @@
1
+ -- CreateTable (implicit many-to-many join for ClassTag <-> Worksheet)
2
+ CREATE TABLE "_WorksheetToTag" (
3
+ "A" TEXT NOT NULL,
4
+ "B" TEXT NOT NULL,
5
+ CONSTRAINT "_WorksheetToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "DiscussionTag"("id") ON DELETE CASCADE ON UPDATE CASCADE,
6
+ CONSTRAINT "_WorksheetToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Worksheet"("id") ON DELETE CASCADE ON UPDATE CASCADE
7
+ );
8
+
9
+ -- CreateIndex
10
+ CREATE UNIQUE INDEX "_WorksheetToTag_AB_unique" ON "_WorksheetToTag"("A", "B");
11
+
12
+ -- CreateIndex
13
+ CREATE INDEX "_WorksheetToTag_B_index" ON "_WorksheetToTag"("B");
@@ -5,7 +5,8 @@
5
5
  // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6
6
 
7
7
  generator client {
8
- provider = "prisma-client-js"
8
+ provider = "prisma-client-js"
9
+ previewFeatures = ["postgresqlExtensions"]
9
10
  // output = "../generated/prisma"
10
11
  }
11
12
 
@@ -15,9 +16,10 @@ generator client {
15
16
  // }
16
17
 
17
18
  datasource db {
18
- provider = "postgresql"
19
- url = env("DATABASE_URL")
20
- directUrl = env("DIRECT_URL")
19
+ provider = "postgresql"
20
+ url = env("DATABASE_URL")
21
+ directUrl = env("DIRECT_URL")
22
+ extensions = [vector]
21
23
  }
22
24
 
23
25
  enum AssignmentType {
@@ -321,7 +323,8 @@ model Class {
321
323
  tags Tag[] @relation("ClassTags")
322
324
  permissions Permission[] @relation("ClassPermissions")
323
325
  discussions Discussion[]
324
- discussionTags DiscussionTag[]
326
+ classTags ClassTag[]
327
+ embeddings Embedding[]
325
328
 
326
329
  discussionsEnabled Boolean @default(true)
327
330
  studentsCanCreateDiscussions Boolean @default(true)
@@ -542,7 +545,7 @@ model Discussion {
542
545
  isLocked Boolean @default(false)
543
546
  allowReplies Boolean @default(true)
544
547
  attachments File[] @relation("DiscussionFiles")
545
- tags DiscussionTag[] @relation("DiscussionToTag")
548
+ tags ClassTag[] @relation("DiscussionToTag")
546
549
  replies DiscussionReply[]
547
550
  reactions Reaction[]
548
551
  reads DiscussionRead[]
@@ -575,15 +578,17 @@ model DiscussionReply {
575
578
  @@index([parentId])
576
579
  }
577
580
 
578
- model DiscussionTag {
581
+ model ClassTag {
579
582
  id String @id @default(uuid())
580
583
  class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
581
584
  classId String
582
585
  name String
583
586
  color String @default("#3B82F6")
584
587
  discussions Discussion[] @relation("DiscussionToTag")
588
+ worksheets Worksheet[] @relation("WorksheetToTag")
585
589
 
586
590
  @@unique([classId, name])
591
+ @@map("DiscussionTag")
587
592
  }
588
593
 
589
594
  model DiscussionRead {
@@ -861,6 +866,7 @@ model Worksheet {
861
866
  runJob RunJob? @relation("WorksheetRunJob", fields: [runJobId], references: [id], onDelete: SetNull)
862
867
 
863
868
  status JobStatus? @default(NOT_STARTED)
869
+ tags ClassTag[] @relation("WorksheetToTag")
864
870
 
865
871
  @@unique([classId, sourceUrl])
866
872
  }
@@ -962,6 +968,31 @@ enum RunJobType {
962
968
  AI_LAB_CHAT
963
969
  AI_NEWTON_CHAT
964
970
  STUDENT_PROGRESS_CHAT
971
+ EMBED_CONTENT
972
+ }
973
+
974
+ enum EmbeddingSourceType {
975
+ FILE
976
+ ASSIGNMENT
977
+ WORKSHEET
978
+ ANNOUNCEMENT
979
+ SECTION
980
+ }
981
+
982
+ model Embedding {
983
+ id String @id @default(uuid())
984
+ classId String
985
+ sourceType EmbeddingSourceType
986
+ sourceId String
987
+ content String
988
+ vector Unsupported("vector(1536)")
989
+ metadata Json? @default("{}")
990
+ createdAt DateTime @default(now())
991
+
992
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
993
+
994
+ @@index([classId])
995
+ @@index([sourceType, sourceId])
965
996
  }
966
997
 
967
998
  enum RunJobStatus {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { v4 as uuidv4 } from 'uuid';
27
27
 
28
28
  import "./instrument.js";
29
29
  import { openAIClient } from './utils/inference.js';
30
+ import { aiStreamRouter } from './routes/aiStream.js';
30
31
 
31
32
  const app = express();
32
33
 
@@ -313,6 +314,9 @@ io.engine.on('connection_error', (err: Error) => {
313
314
  // Setup socket handlers
314
315
  setupSocketHandlers(io);
315
316
 
317
+ // AI streaming routes
318
+ app.use('/api/ai', aiStreamRouter);
319
+
316
320
  // File serving endpoint for secure file access
317
321
  app.get('/api/files/:fileId', async (req, res) => {
318
322
  try {
@@ -5,18 +5,24 @@ type CorsEnv = {
5
5
  CORS_ALLOWED_ORIGIN_PATTERNS?: string;
6
6
  };
7
7
 
8
- const productionOrigins = [
8
+ // First-party Studious domains. These are always allowed, regardless of
9
+ // NODE_ENV, so local/dev servers can still serve the real frontends.
10
+ const studiousOrigins = [
9
11
  'https://www.studious.sh',
10
12
  'https://studious.sh',
11
13
  'https://dev.studious.sh',
12
- // @todo: remove in actual prod
13
- 'http://localhost:3000',
14
- 'http://localhost:3001',
15
- 'http://127.0.0.1:3000',
16
- 'http://127.0.0.1:3001',
14
+ 'https://superuser.studious.sh',
15
+ 'https://*.studious.sh',
16
+ 'https://www.studious.ac',
17
+ 'https://studious.ac',
18
+ 'https://dev.studious.ac',
19
+ 'https://superuser.studious.ac',
20
+ 'https://*.studious.ac',
17
21
  ];
18
22
 
19
- const nonProductionOrigins = [
23
+ // Local development frontends.
24
+ // @todo: gate these behind a non-production check before real prod.
25
+ const localhostOrigins = [
20
26
  'http://localhost:3000',
21
27
  'http://localhost:3001',
22
28
  'http://127.0.0.1:3000',
@@ -40,6 +46,17 @@ const parseList = (value?: string) =>
40
46
 
41
47
  const isDefined = <T>(value: T | undefined | null): value is T => Boolean(value);
42
48
 
49
+ const hasWildcard = (value: string) => value.includes('*');
50
+
51
+ // Turn a wildcard origin like `https://*.studious.ac` into an anchored regex.
52
+ // `*` matches one or more subdomain labels (e.g. `app` or `a.b`).
53
+ const wildcardOriginToRegExp = (value: string) => {
54
+ const escaped = value
55
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
+ .replace(/\\\*/g, '[a-z0-9-]+(?:\\.[a-z0-9-]+)*');
57
+ return new RegExp(`^${escaped}$`, 'i');
58
+ };
59
+
43
60
  const toCorsOrigin = (value: string) => {
44
61
  try {
45
62
  return new URL(value).origin;
@@ -79,14 +96,20 @@ export const isValidCorsOriginPatternList = (patterns?: string) => {
79
96
  };
80
97
 
81
98
  export const createCorsOriginMatcher = (env: CorsEnv) => {
82
- const allowedOrigins = Array.from(new Set([
83
- ...(env.NODE_ENV === 'production' ? productionOrigins : nonProductionOrigins),
99
+ const configuredOrigins = Array.from(new Set([
100
+ ...studiousOrigins,
101
+ ...localhostOrigins,
84
102
  env.NEXT_PUBLIC_APP_URL ? toCorsOrigin(env.NEXT_PUBLIC_APP_URL) : undefined,
85
103
  ...parseCorsOrigins(env.CORS_ALLOWED_ORIGINS),
86
104
  ].filter(isDefined)));
87
105
 
106
+ // Wildcard entries (e.g. `https://*.studious.ac`) can't be matched by exact
107
+ // string comparison, so they're compiled into regex patterns instead.
108
+ const allowedOrigins = configuredOrigins.filter((origin) => !hasWildcard(origin));
109
+
88
110
  const allowedOriginPatterns = [
89
111
  vercelAppOriginPattern,
112
+ ...configuredOrigins.filter(hasWildcard).map(wildcardOriginToRegExp),
90
113
  ...parseCorsOriginPatterns(env.CORS_ALLOWED_ORIGIN_PATTERNS),
91
114
  ];
92
115
 
@@ -35,6 +35,12 @@ const baseSchema = z.object({
35
35
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
36
36
  PORT: z.string().transform(Number).default('3001'),
37
37
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
38
+ /**
39
+ * Optional BullMQ key prefix. Defaults to `bull:<NODE_ENV>` (see
40
+ * `getBullPrefix`). Set explicitly to isolate queues when multiple
41
+ * databases share one Redis instance (e.g. several devs on one Upstash).
42
+ */
43
+ BULL_PREFIX: z.string().optional(),
38
44
  });
39
45
 
40
46
  // Production/development schema with all required vars
@@ -98,4 +98,25 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
98
98
  message: 'Failed to copy file in storage',
99
99
  });
100
100
  }
101
+ }
102
+
103
+ /**
104
+ * Returns the public URL for a GCS object.
105
+ * The object must be publicly readable (via ACL or bucket-level IAM).
106
+ */
107
+ export function getPublicUrl(filePath: string): string {
108
+ return `https://storage.googleapis.com/${env.GOOGLE_CLOUD_BUCKET_NAME}/${filePath}`;
109
+ }
110
+
111
+ /**
112
+ * Best-effort attempt to make a GCS object publicly readable.
113
+ * Silently ignores errors (e.g. uniform bucket-level access enabled).
114
+ */
115
+ export async function makeFilePublic(filePath: string): Promise<void> {
116
+ try {
117
+ await bucket.file(filePath).makePublic();
118
+ } catch {
119
+ // Bucket may use uniform access or already have a public IAM policy – either way, the
120
+ // public URL will work if the bucket allows it; nothing to do here.
121
+ }
101
122
  }
package/src/lib/prisma.ts CHANGED
@@ -30,6 +30,19 @@ const getDatabaseUrl = () => {
30
30
  }
31
31
  };
32
32
 
33
+ /**
34
+ * Resolve a profile-picture value stored in the DB to a browser-loadable URL.
35
+ * - `null` / empty → `null`
36
+ * - already an HTTP URL (e.g. dicebear) → returned as-is
37
+ * - raw GCS object path → converted to a public GCS URL
38
+ */
39
+ function resolveProfilePictureUrl(raw: string | null): string | null {
40
+ if (!raw) return null;
41
+ if (raw.startsWith('http')) return raw;
42
+ const bucket = env.GOOGLE_CLOUD_BUCKET_NAME;
43
+ return `https://storage.googleapis.com/${bucket}/${raw}`;
44
+ }
45
+
33
46
  const prismaClientSingleton = () => {
34
47
  return new PrismaClient({
35
48
  datasources: {
@@ -37,6 +50,23 @@ const prismaClientSingleton = () => {
37
50
  url: getDatabaseUrl(),
38
51
  },
39
52
  },
53
+ }).$extends({
54
+ result: {
55
+ userProfile: {
56
+ profilePicture: {
57
+ needs: { profilePicture: true },
58
+ compute(profile) {
59
+ return resolveProfilePictureUrl(profile.profilePicture);
60
+ },
61
+ },
62
+ profilePictureThumbnail: {
63
+ needs: { profilePictureThumbnail: true },
64
+ compute(profile) {
65
+ return resolveProfilePictureUrl(profile.profilePictureThumbnail);
66
+ },
67
+ },
68
+ },
69
+ },
40
70
  });
41
71
  };
42
72
 
@@ -53,4 +83,13 @@ if (env.NODE_ENV !== 'production') {
53
83
 
54
84
  process.on('beforeExit', async () => {
55
85
  await prisma.$disconnect();
56
- });
86
+ });
87
+
88
+ /** The concrete (extended) Prisma client type used throughout the app. */
89
+ export type AppPrismaClient = typeof prisma;
90
+
91
+ /** Transaction client type compatible with the extended Prisma client. */
92
+ export type AppTransactionClient = Omit<
93
+ AppPrismaClient,
94
+ '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
95
+ >;
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { Assignment, Class, Folder, GradingBoundary, MarkScheme, Section, SessionType, Worksheet } from "@prisma/client";
8
- import type { PrismaClient, WorksheetQuestion } from "@prisma/client";
8
+ import type { WorksheetQuestion } from "@prisma/client";
9
+ import type { AppPrismaClient, AppTransactionClient } from "../lib/prisma.js";
9
10
 
10
11
  import { prisma } from "../lib/prisma.js";
11
12
  import { v4 as uuidv4 } from 'uuid';
@@ -477,7 +478,7 @@ async function recursivelyCopyFilesToGcs(folderId: string): Promise<Map<string,
477
478
  }
478
479
 
479
480
  async function recursivelyCreateFiles(
480
- tx: PrismaClient | Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends">,
481
+ tx: AppPrismaClient | AppTransactionClient,
481
482
  folderId: string,
482
483
  targetFolderId: string | undefined,
483
484
  pathMap: Map<string, string>
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Worksheet model – worksheets, questions, submissions, student progress, grading.
3
3
  */
4
- import type { Prisma } from "@prisma/client";
5
4
  import { prisma } from "../lib/prisma.js";
5
+ import type { AppTransactionClient } from "../lib/prisma.js";
6
6
  import type { WorksheetQuestionType, JobStatus } from "@prisma/client";
7
7
  import {
8
8
  classMemberOrSchoolAdminWhere,
@@ -45,6 +45,7 @@ export function findWorksheetByIdForClassMember(id: string, userId: string) {
45
45
  include: {
46
46
  questions: { orderBy: { order: "asc" as const } },
47
47
  class: true,
48
+ tags: true,
48
49
  },
49
50
  });
50
51
  }
@@ -59,6 +60,7 @@ export function findWorksheetByIdForTeacher(id: string, userId: string) {
59
60
  include: {
60
61
  questions: { orderBy: { order: "asc" as const } },
61
62
  class: true,
63
+ tags: true,
62
64
  },
63
65
  });
64
66
  }
@@ -69,6 +71,7 @@ export function findWorksheetsByClassId(classId: string) {
69
71
  where: { classId },
70
72
  include: {
71
73
  questions: { select: { id: true } },
74
+ tags: true,
72
75
  },
73
76
  orderBy: { createdAt: "desc" },
74
77
  });
@@ -121,7 +124,7 @@ export function deleteWorksheet(id: string) {
121
124
  * Does not copy student responses or assignment links.
122
125
  */
123
126
  export async function duplicateWorksheetWithClient(
124
- tx: Prisma.TransactionClient,
127
+ tx: AppTransactionClient,
125
128
  sourceWorksheetId: string,
126
129
  targetClassId: string,
127
130
  name: string
@@ -7,7 +7,6 @@ import { prisma } from "../lib/prisma.js";
7
7
  import { pusher, teacherChannel } from "../lib/pusher.js";
8
8
  import type { Assignment, Class, File, Section, User } from "@prisma/client";
9
9
  import {
10
- inference,
11
10
  completePendingAIMessage,
12
11
  failPendingAIMessage,
13
12
  } from "../utils/inference.js";
@@ -16,8 +15,11 @@ import { createPdf } from "../lib/jsonConversion.js";
16
15
  import { v4 } from "uuid";
17
16
  import { bucket } from "../lib/googleCloudStorage.js";
18
17
  import { DocumentBlock } from "../lib/jsonStyles.js";
19
- import { type LabChatResponse, labChatResponseSchema } from "./aiLabChatContract.js";
20
- import { buildLabChatResponseMessages } from "./labChatPrompt.js";
18
+ import { broadcastTextChunks, broadcastStreamComplete } from "../utils/streamBroadcast.js";
19
+ import { buildLabChatAgentMessages } from "./labChatPrompt.js";
20
+ import { streamChatResponse } from "../services/aiChatStream.js";
21
+ import { aiTools } from "../services/aiTools.js";
22
+ import { labProposalTools, createLabProposals } from "../services/aiProposalTools.js";
21
23
 
22
24
  /** Cap model wait time; leave headroom after inference for PDF/GCS/DB within Vercel maxDuration (500s). */
23
25
  const LAB_CHAT_RESPONSE_TIMEOUT_MS = 400_000;
@@ -303,33 +305,6 @@ export const getBaseSystemPrompt = (
303
305
 
304
306
 
305
307
 
306
- /**
307
- * Generate labchat responses
308
- * Allow for the generation of the following:
309
- * - Assignment(s) either individual or bulk as an lesson / course plan.
310
- * - Worksheet(s) either individual or bulk as an lesson / course plan.
311
- * - Files (PDFs)
312
- * @param labChatId
313
- */
314
- // export const sendAiLabChatResponsePipeline = async (labChatId: string) => {
315
- // const message = await prisma?.message.create({
316
- // data: {
317
- // content: "GENERATING_CONTENT",
318
- // senderId: getAIUserId(),
319
- // conversationId: labChatId,
320
- // status: JobStatus.PENDING,
321
- // },
322
- // });
323
-
324
- // try {
325
-
326
- // inference(`
327
- // `)
328
- // }
329
-
330
- // };
331
-
332
-
333
308
  /**
334
309
  * Fill the AI introduction placeholder for a brand-new lab chat.
335
310
  *
@@ -348,6 +323,7 @@ export const generateAndSendLabIntroduction = async (
348
323
  "Hello teaching team! I'm your AI assistant for course material development. I'll help you create educational content - when you have a clear request, I'll produce it directly. I only ask questions when I need to clarify the topic or scope. What would you like to work on?";
349
324
 
350
325
  try {
326
+ await broadcastTextChunks(conversationId, aiMessageId, introMessage);
351
327
  await completePendingAIMessage(aiMessageId, introMessage);
352
328
  logger.info('AI Introduction sent', { labChatId, conversationId, aiMessageId });
353
329
  } catch (error) {
@@ -474,50 +450,51 @@ export const generateAndSendLabIntroduction = async (
474
450
  assignments: classData.assignments,
475
451
  });
476
452
 
477
- const messages = buildLabChatResponseMessages({
453
+ const { system, messages } = buildLabChatAgentMessages({
478
454
  context: fullLabChat.context,
479
455
  classContext,
480
456
  recentMessages: recentMessages.reverse(),
481
457
  isAIUser,
482
458
  });
483
-
484
-
485
- // const completion = await inferenceClient.chat.completions.create({
486
- // model: 'command-a-03-2025',
487
- // messages,
488
- // temperature: 0.7,
489
- // response_format: zodTextFormat(labChatResponseSchema, "lab_chat_response_format"),
490
- // });
491
-
492
- const response = await withTimeout(
493
- inference<LabChatResponse>(messages, labChatResponseSchema, {
494
- reason: 'lab_chat_response',
495
- metadata: {
496
- labChatId,
459
+
460
+ // Genuine agentic generation: the model streams prose token-by-token and
461
+ // calls real tools inline — read-only RAG lookups plus "propose_*" tools
462
+ // that record create-actions into `proposals`. `streamChatResponse`
463
+ // broadcasts the real text chunks and tool-call events as they happen.
464
+ const proposals = createLabProposals();
465
+ const tools = {
466
+ ...aiTools(fullLabChat.classId),
467
+ ...labProposalTools(proposals),
468
+ };
469
+
470
+ const { text: streamedText, parts: streamParts } = await withTimeout(
471
+ streamChatResponse({
497
472
  conversationId,
498
- classId: fullLabChat.classId,
499
473
  aiMessageId: emitOptions.aiMessageId,
500
- },
501
- }),
502
- LAB_CHAT_RESPONSE_TIMEOUT_MS,
503
- "Lab chat response generation",
504
- );
505
-
506
- if (!response) {
507
- throw new Error('No response generated from inference API');
508
- }
509
- // Parse the JSON response and generate PDF if docs are provided
474
+ system,
475
+ messages,
476
+ tools,
477
+ temperature: 0.7,
478
+ maxOutputTokens: 4096,
479
+ maxSteps: 8,
480
+ }),
481
+ LAB_CHAT_RESPONSE_TIMEOUT_MS,
482
+ "Lab chat response generation",
483
+ );
484
+
485
+ // Real text + tool-call events were already broadcast inline (in order)
486
+ // during the stream by `streamChatResponse`. Just close the stream.
487
+ await broadcastStreamComplete(conversationId, emitOptions.aiMessageId);
488
+
489
+ // Generate PDFs for any proposed documents and persist the message.
510
490
  try {
511
- const jsonData = response;
512
-
513
-
514
491
  const attachmentIds: string[] = [];
515
- // Generate PDFs if docs are provided
516
- if (jsonData.docs && Array.isArray(jsonData.docs)) {
492
+ // Generate PDFs if docs were proposed
493
+ if (proposals.docs.length > 0) {
517
494
 
518
495
 
519
- for (let i = 0; i < jsonData.docs.length; i++) {
520
- const doc = jsonData.docs[i];
496
+ for (let i = 0; i < proposals.docs.length; i++) {
497
+ const doc = proposals.docs[i];
521
498
  if (!doc.title || !doc.blocks || !Array.isArray(doc.blocks)) {
522
499
  logger.error(`Document ${i + 1} is missing title or blocks`);
523
500
  continue;
@@ -577,30 +554,28 @@ export const generateAndSendLabIntroduction = async (
577
554
  }
578
555
  }
579
556
 
580
- await completePendingAIMessage(emitOptions.aiMessageId, jsonData.text, {
557
+ await completePendingAIMessage(emitOptions.aiMessageId, streamedText, {
581
558
  attachments: {
582
559
  connect: attachmentIds.map(id => ({ id })),
583
560
  },
584
561
  meta: {
585
- assignmentsToCreate: jsonData.assignmentsToCreate?.map(assignment => ({
586
- ...assignment,
587
- id: v4(),
588
- })) || null,
589
- worksheetsToCreate: jsonData.worksheetsToCreate?.map(worksheet => ({
590
- ...worksheet,
591
- id: v4(),
592
- })) || null,
593
- sectionsToCreate: jsonData.sectionsToCreate?.map(section => ({
594
- ...section,
595
- id: v4(),
596
- })) || null,
562
+ assignmentsToCreate: proposals.assignments.length > 0
563
+ ? proposals.assignments.map(assignment => ({ ...assignment, id: v4() }))
564
+ : null,
565
+ worksheetsToCreate: proposals.worksheets.length > 0
566
+ ? proposals.worksheets.map(worksheet => ({ ...worksheet, id: v4() }))
567
+ : null,
568
+ sectionsToCreate: proposals.sections.length > 0
569
+ ? proposals.sections.map(section => ({ ...section, id: v4() }))
570
+ : null,
571
+ streamParts: streamParts.length > 0 ? streamParts : null,
597
572
  },
598
573
  });
599
- } catch (parseError) {
600
- logger.error('Failed to parse AI response or generate PDF:', { error: parseError, labChatId });
574
+ } catch (persistError) {
575
+ logger.error('Failed to persist AI response or generate PDF:', { error: persistError, labChatId });
601
576
  // Fallback: complete the placeholder with the raw text so the
602
577
  // teacher still sees something useful instead of FAILED.
603
- await completePendingAIMessage(emitOptions.aiMessageId, response.text);
578
+ await completePendingAIMessage(emitOptions.aiMessageId, streamedText);
604
579
  }
605
580
 
606
581
  try {