@studious-lms/server 1.4.7 → 1.5.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 (201) hide show
  1. package/.github/workflows/npm.yml +28 -0
  2. package/dist/lib/config/cors.d.ts.map +1 -1
  3. package/dist/lib/config/cors.js +2 -4
  4. package/dist/lib/config/cors.js.map +1 -1
  5. package/dist/lib/fileUpload.d.ts.map +1 -1
  6. package/dist/lib/fileUpload.js +4 -5
  7. package/dist/lib/fileUpload.js.map +1 -1
  8. package/dist/models/assignment.d.ts +32 -0
  9. package/dist/models/assignment.d.ts.map +1 -1
  10. package/dist/models/assignment.js +111 -2
  11. package/dist/models/assignment.js.map +1 -1
  12. package/dist/models/auth.d.ts +1 -0
  13. package/dist/models/auth.d.ts.map +1 -1
  14. package/dist/models/auth.js +3 -2
  15. package/dist/models/auth.js.map +1 -1
  16. package/dist/models/comment.d.ts +0 -5
  17. package/dist/models/comment.d.ts.map +1 -1
  18. package/dist/models/conversation.d.ts +2 -1
  19. package/dist/models/conversation.d.ts.map +1 -1
  20. package/dist/models/labChat.d.ts +2 -1
  21. package/dist/models/labChat.d.ts.map +1 -1
  22. package/dist/models/message.d.ts +6 -3
  23. package/dist/models/message.d.ts.map +1 -1
  24. package/dist/models/school.d.ts +0 -22
  25. package/dist/models/school.d.ts.map +1 -1
  26. package/dist/models/school.js +2 -14
  27. package/dist/models/school.js.map +1 -1
  28. package/dist/models/worksheet.d.ts +174 -95
  29. package/dist/models/worksheet.d.ts.map +1 -1
  30. package/dist/models/worksheet.js +38 -5
  31. package/dist/models/worksheet.js.map +1 -1
  32. package/dist/pipelines/aiLabChat.js +6 -6
  33. package/dist/pipelines/aiLabChat.js.map +1 -1
  34. package/dist/pipelines/gradeWorksheet.d.ts +5 -3
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +60 -24
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/pipelines/pdfToWorksheet.d.ts +3 -0
  39. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  40. package/dist/pipelines/pdfToWorksheet.js +65 -24
  41. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  42. package/dist/routers/_app.d.ts +232 -48
  43. package/dist/routers/_app.d.ts.map +1 -1
  44. package/dist/routers/announcement.d.ts +0 -4
  45. package/dist/routers/announcement.d.ts.map +1 -1
  46. package/dist/routers/assignment.d.ts +35 -8
  47. package/dist/routers/assignment.d.ts.map +1 -1
  48. package/dist/routers/assignment.js +10 -3
  49. package/dist/routers/assignment.js.map +1 -1
  50. package/dist/routers/auth.d.ts +1 -0
  51. package/dist/routers/auth.d.ts.map +1 -1
  52. package/dist/routers/class.d.ts +19 -0
  53. package/dist/routers/class.d.ts.map +1 -1
  54. package/dist/routers/comment.d.ts +0 -3
  55. package/dist/routers/comment.d.ts.map +1 -1
  56. package/dist/routers/conversation.d.ts +2 -1
  57. package/dist/routers/conversation.d.ts.map +1 -1
  58. package/dist/routers/labChat.d.ts +2 -1
  59. package/dist/routers/labChat.d.ts.map +1 -1
  60. package/dist/routers/school.d.ts +6 -0
  61. package/dist/routers/school.d.ts.map +1 -1
  62. package/dist/routers/school.js +7 -2
  63. package/dist/routers/school.js.map +1 -1
  64. package/dist/routers/worksheet.d.ts +51 -7
  65. package/dist/routers/worksheet.d.ts.map +1 -1
  66. package/dist/routers/worksheet.js +10 -3
  67. package/dist/routers/worksheet.js.map +1 -1
  68. package/dist/services/assignment.d.ts +30 -0
  69. package/dist/services/assignment.d.ts.map +1 -1
  70. package/dist/services/assignment.js +37 -3
  71. package/dist/services/assignment.js.map +1 -1
  72. package/dist/services/auth.d.ts +1 -0
  73. package/dist/services/auth.d.ts.map +1 -1
  74. package/dist/services/class.d.ts +19 -0
  75. package/dist/services/class.d.ts.map +1 -1
  76. package/dist/services/class.js +37 -19
  77. package/dist/services/class.js.map +1 -1
  78. package/dist/services/comment.d.ts +0 -3
  79. package/dist/services/comment.d.ts.map +1 -1
  80. package/dist/services/conversation.d.ts +2 -1
  81. package/dist/services/conversation.d.ts.map +1 -1
  82. package/dist/services/labChat.d.ts +2 -1
  83. package/dist/services/labChat.d.ts.map +1 -1
  84. package/dist/services/labChat.js +6 -6
  85. package/dist/services/labChat.js.map +1 -1
  86. package/dist/services/message.d.ts.map +1 -1
  87. package/dist/services/message.js +14 -2
  88. package/dist/services/message.js.map +1 -1
  89. package/dist/services/school.d.ts +6 -0
  90. package/dist/services/school.d.ts.map +1 -1
  91. package/dist/services/school.js +71 -6
  92. package/dist/services/school.js.map +1 -1
  93. package/dist/services/worksheet.d.ts +46 -7
  94. package/dist/services/worksheet.d.ts.map +1 -1
  95. package/dist/services/worksheet.js +52 -20
  96. package/dist/services/worksheet.js.map +1 -1
  97. package/dist/utils/generateUsername.d.ts +8 -0
  98. package/dist/utils/generateUsername.d.ts.map +1 -0
  99. package/dist/utils/generateUsername.js +103 -0
  100. package/dist/utils/generateUsername.js.map +1 -0
  101. package/dist/workers/connection.d.ts +3 -0
  102. package/dist/workers/connection.d.ts.map +1 -0
  103. package/dist/workers/connection.js +33 -0
  104. package/dist/workers/connection.js.map +1 -0
  105. package/dist/workers/index.d.ts +10 -0
  106. package/dist/workers/index.d.ts.map +1 -0
  107. package/dist/workers/index.js +53 -0
  108. package/dist/workers/index.js.map +1 -0
  109. package/dist/workers/processors/pdfToWorksheet.d.ts +9 -0
  110. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
  111. package/dist/workers/processors/pdfToWorksheet.js +100 -0
  112. package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
  113. package/dist/workers/queues.d.ts +69 -0
  114. package/dist/workers/queues.d.ts.map +1 -0
  115. package/dist/workers/queues.js +119 -0
  116. package/dist/workers/queues.js.map +1 -0
  117. package/package.json +4 -1
  118. package/prisma/migrations/20260504071500_school_memberships/migration.sql +1 -1
  119. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
  120. package/prisma/schema.prisma +465 -387
  121. package/src/lib/config/cors.ts +0 -2
  122. package/src/lib/fileUpload.ts +2 -4
  123. package/src/models/assignment.ts +132 -0
  124. package/src/models/auth.ts +1 -0
  125. package/src/models/school.ts +0 -14
  126. package/src/models/worksheet.ts +56 -2
  127. package/src/pipelines/aiLabChat.ts +4 -4
  128. package/src/pipelines/gradeWorksheet.ts +63 -25
  129. package/src/pipelines/pdfToWorksheet.ts +68 -22
  130. package/src/routers/assignment.ts +19 -0
  131. package/src/routers/school.ts +5 -0
  132. package/src/routers/worksheet.ts +18 -0
  133. package/src/services/assignment.ts +47 -1
  134. package/src/services/class.ts +41 -17
  135. package/src/services/labChat.ts +4 -4
  136. package/src/services/message.ts +13 -0
  137. package/src/services/school.ts +89 -6
  138. package/src/services/worksheet.ts +62 -18
  139. package/src/utils/generateUsername.ts +108 -0
  140. package/src/workers/connection.ts +37 -0
  141. package/src/workers/index.ts +61 -0
  142. package/src/workers/processors/pdfToWorksheet.ts +119 -0
  143. package/src/workers/queues.ts +159 -0
  144. package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
  145. package/tests/{helpers.ts → integration/helpers.ts} +2 -2
  146. package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
  147. package/tests/{routers → integration/routers}/attendance.test.ts +1 -1
  148. package/tests/{routers → integration/routers}/auth.test.ts +3 -3
  149. package/tests/{routers → integration/routers}/class.test.ts +3 -17
  150. package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
  151. package/tests/{routers → integration/routers}/event.test.ts +1 -1
  152. package/tests/{routers → integration/routers}/message.test.ts +1 -1
  153. package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
  154. package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
  155. package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
  156. package/tests/integration/routers/worksheet.test.ts +448 -0
  157. package/tests/{setup.ts → integration/setup.ts} +4 -4
  158. package/tests/{lib → unit/lib}/cors.test.ts +1 -1
  159. package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
  160. package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
  161. package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
  162. package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
  163. package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
  164. package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
  165. package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
  166. package/tests/{utils → unit/utils}/inference.test.ts +6 -6
  167. package/tests/{utils → unit/utils}/logger.test.ts +1 -1
  168. package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
  169. package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
  170. package/vitest.config.ts +11 -3
  171. package/vitest.unit.config.ts +10 -10
  172. package/dist/lib/notificationHandler.d.ts +0 -25
  173. package/dist/lib/notificationHandler.d.ts.map +0 -1
  174. package/dist/lib/notificationHandler.js +0 -32
  175. package/dist/lib/notificationHandler.js.map +0 -1
  176. package/dist/server/pipelines/aiLabChat.d.ts +0 -21
  177. package/dist/server/pipelines/aiLabChat.d.ts.map +0 -1
  178. package/dist/server/pipelines/aiLabChat.js +0 -456
  179. package/dist/server/pipelines/aiLabChat.js.map +0 -1
  180. package/dist/server/pipelines/aiNewtonChat.d.ts +0 -30
  181. package/dist/server/pipelines/aiNewtonChat.d.ts.map +0 -1
  182. package/dist/server/pipelines/aiNewtonChat.js +0 -285
  183. package/dist/server/pipelines/aiNewtonChat.js.map +0 -1
  184. package/dist/server/pipelines/gradeWorksheet.d.ts +0 -30
  185. package/dist/server/pipelines/gradeWorksheet.d.ts.map +0 -1
  186. package/dist/server/pipelines/gradeWorksheet.js +0 -248
  187. package/dist/server/pipelines/gradeWorksheet.js.map +0 -1
  188. package/tests/routers/worksheet.test.ts +0 -194
  189. package/tests/startup.test.ts +0 -5
  190. package/tests/worksheet.test.ts +0 -181
  191. /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
  192. /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
  193. /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
  194. /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
  195. /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
  196. /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
  197. /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
  198. /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
  199. /package/tests/{routers → integration/routers}/search.test.ts +0 -0
  200. /package/tests/{routers → integration/routers}/section.test.ts +0 -0
  201. /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pdfToWorksheet.js","sources":["workers/processors/pdfToWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { Worker, type Job } from 'bullmq';\nimport { Prisma } from '@prisma/client';\nimport { bullConnection } from '../connection.js';\nimport { prisma } from '../../lib/prisma.js';\nimport { logger } from '../../utils/logger.js';\nimport { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';\n\ninterface PdfToWorksheetJobData {\n runJobId: string;\n gcsUrl: string;\n classId: string;\n}\n\nasync function process(job: Job<PdfToWorksheetJobData>) {\n const { runJobId, gcsUrl, classId } = job.data;\n\n // If a previous attempt already succeeded for this RunJob (rare race after a\n // crash that lost the BullMQ ack), return the prior result instead of\n // re-running the AI call and creating a duplicate worksheet.\n const existing = await prisma.runJob.findUnique({\n where: { id: runJobId },\n select: { status: true, result: true },\n });\n if (existing?.status === 'SUCCEEDED') {\n logger.info('pdfToWorksheet RunJob already succeeded; skipping re-run', {\n runJobId,\n bullJobId: job.id,\n });\n return existing.result;\n }\n\n await prisma.runJob.update({\n where: { id: runJobId },\n data: {\n status: 'RUNNING',\n startedAt: existing?.status === 'RUNNING' ? undefined : new Date(),\n attempts: { increment: 1 },\n },\n });\n\n const worksheet = await pdfToWorksheet(gcsUrl, classId);\n\n const result = worksheet ? { worksheetId: worksheet.id, name: worksheet.name } : null;\n\n await prisma.runJob.update({\n where: { id: runJobId },\n data: {\n status: 'SUCCEEDED',\n finishedAt: new Date(),\n error: null,\n result: result === null ? Prisma.JsonNull : (result as Prisma.InputJsonValue),\n },\n });\n\n return result;\n}\n\nexport const pdfToWorksheetWorker = new Worker<PdfToWorksheetJobData>(\n 'pdfToWorksheet',\n process,\n {\n connection: bullConnection,\n // Conservative defaults — tune once we have real-world numbers.\n concurrency: 2,\n limiter: {\n max: 5,\n duration: 60_000, // 5 jobs / minute, e.g. to stay under OpenAI rate limits\n },\n },\n);\n\npdfToWorksheetWorker.on('error', (err) => {\n logger.error('pdfToWorksheet worker error', {\n error: err.message,\n stack: err.stack,\n });\n});\n\npdfToWorksheetWorker.on('failed', async (job, err) => {\n if (!job) return;\n\n const runJobId = job.data?.runJobId;\n const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? 1);\n\n logger.error('pdfToWorksheet job failed', {\n bullJobId: job.id,\n runJobId,\n attempt: job.attemptsMade,\n finalAttempt: isFinalAttempt,\n error: err.message,\n });\n\n if (!runJobId) return;\n\n // Always record the latest error message on the RunJob row.\n // Only flip to FAILED on the final attempt — earlier failures are still\n // \"RUNNING\" because BullMQ is going to retry.\n await prisma.runJob.update({\n where: { id: runJobId },\n data: {\n error: err.message,\n ...(isFinalAttempt\n ? { status: 'FAILED', finishedAt: new Date() }\n : {}),\n },\n }).catch((updateError) => {\n logger.error('Failed to record pdfToWorksheet failure on RunJob', {\n runJobId,\n error: updateError instanceof Error ? updateError.message : String(updateError),\n });\n });\n});\n\npdfToWorksheetWorker.on('completed', (job) => {\n logger.info('pdfToWorksheet job completed', {\n bullJobId: job.id,\n runJobId: job.data?.runJobId,\n });\n});\n"],"names":[],"mappings":"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","debug_id":"1b13378b-3952-5f91-b138-a69c3693a56d"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * BullMQ queue producers and the `enqueueRunJob` helper.
3
+ *
4
+ * Producer side only — safe to import from HTTP routes / services running on
5
+ * Vercel. Importing this file does NOT start any workers; workers live in
6
+ * `src/workers/processors/*` and are wired up by `src/workers/index.ts`,
7
+ * which is the entrypoint of the separate long-running worker process.
8
+ */
9
+ import { type JobsOptions } from 'bullmq';
10
+ import { type RunJobType } from '@prisma/client';
11
+ /**
12
+ * Map RunJobType → (queue name, input payload shape).
13
+ *
14
+ * Add a new entry here whenever you introduce a new background job type.
15
+ * The queue name string must match what the corresponding processor passes
16
+ * to `new Worker(<name>, ...)`.
17
+ */
18
+ type JobInputs = {
19
+ PDF_TO_WORKSHEET: {
20
+ gcsUrl: string;
21
+ classId: string;
22
+ };
23
+ GRADE_WORKSHEET: {
24
+ worksheetResponseId: string;
25
+ };
26
+ AI_LAB_CHAT: {
27
+ conversationId: string;
28
+ messageId: string;
29
+ };
30
+ AI_NEWTON_CHAT: {
31
+ conversationId: string;
32
+ messageId: string;
33
+ };
34
+ STUDENT_PROGRESS_CHAT: {
35
+ classId: string;
36
+ studentId: string;
37
+ message: string;
38
+ };
39
+ };
40
+ export interface EnqueueOptions {
41
+ classId?: string | null;
42
+ userId?: string | null;
43
+ /** Override BullMQ job options (attempts, delay, priority, etc.). */
44
+ jobOptions?: JobsOptions;
45
+ }
46
+ export interface EnqueueResult {
47
+ runJobId: string;
48
+ bullJobId: string | null;
49
+ }
50
+ /**
51
+ * Create a `RunJob` audit row, enqueue the BullMQ job, and link the two.
52
+ *
53
+ * Returns immediately — the actual work happens on the worker process. The
54
+ * caller can return `runJobId` to the client and have them poll `getRunJob`
55
+ * (or subscribe over Pusher) to track progress.
56
+ *
57
+ * IMPORTANT: do not call this inside a Prisma `$transaction`. If the outer
58
+ * transaction rolls back after the BullMQ job is added, the worker will pick
59
+ * up an orphaned job pointing at a non-existent RunJob row. Enqueue *after*
60
+ * the transaction commits.
61
+ */
62
+ export declare function enqueueRunJob<T extends RunJobType>(type: T, input: JobInputs[T], options?: EnqueueOptions): Promise<EnqueueResult>;
63
+ /**
64
+ * Close every cached queue. Call from the HTTP server's shutdown handler so
65
+ * the producer side disconnects from Redis cleanly.
66
+ */
67
+ export declare function closeAllQueues(): Promise<void>;
68
+ export {};
69
+ //# sourceMappingURL=queues.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queues.d.ts","sourceRoot":"/","sources":["workers/queues.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAS,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAU,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAYzD;;;;;;GAMG;AACH,KAAK,SAAS,GAAG;IACf,gBAAgB,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,eAAe,EAAE;QAAE,mBAAmB,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,WAAW,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,cAAc,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9D,qBAAqB,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF,CAAC;AA4BF,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,WAAW,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,CAAC,SAAS,UAAU,EACtD,IAAI,EAAE,CAAC,EACP,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,aAAa,CAAC,CA2DxB;AAED;;;GAGG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAGpD"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * BullMQ queue producers and the `enqueueRunJob` helper.
3
+ *
4
+ * Producer side only — safe to import from HTTP routes / services running on
5
+ * Vercel. Importing this file does NOT start any workers; workers live in
6
+ * `src/workers/processors/*` and are wired up by `src/workers/index.ts`,
7
+ * which is the entrypoint of the separate long-running worker process.
8
+ */
9
+
10
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f8634186-ae9e-551f-96ec-058802ec2c99")}catch(e){}}();
11
+ import { Queue } from 'bullmq';
12
+ import { bullConnection } from './connection.js';
13
+ import { prisma } from '../lib/prisma.js';
14
+ import { logger } from '../utils/logger.js';
15
+ const DEFAULT_JOB_OPTIONS = {
16
+ attempts: 3,
17
+ backoff: { type: 'exponential', delay: 5000 },
18
+ removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop
19
+ removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection
20
+ };
21
+ const QUEUE_NAMES = {
22
+ PDF_TO_WORKSHEET: 'pdfToWorksheet',
23
+ GRADE_WORKSHEET: 'gradeWorksheet',
24
+ AI_LAB_CHAT: 'aiLabChat',
25
+ AI_NEWTON_CHAT: 'aiNewtonChat',
26
+ STUDENT_PROGRESS_CHAT: 'studentProgressChat',
27
+ };
28
+ /**
29
+ * One Queue instance per type. Created lazily so that importing this module
30
+ * does not eagerly open queues you may never use in a given runtime.
31
+ */
32
+ const queueCache = new Map();
33
+ function getQueue(type) {
34
+ let queue = queueCache.get(type);
35
+ if (!queue) {
36
+ queue = new Queue(QUEUE_NAMES[type], {
37
+ connection: bullConnection,
38
+ defaultJobOptions: DEFAULT_JOB_OPTIONS,
39
+ });
40
+ queueCache.set(type, queue);
41
+ }
42
+ return queue;
43
+ }
44
+ /**
45
+ * Create a `RunJob` audit row, enqueue the BullMQ job, and link the two.
46
+ *
47
+ * Returns immediately — the actual work happens on the worker process. The
48
+ * caller can return `runJobId` to the client and have them poll `getRunJob`
49
+ * (or subscribe over Pusher) to track progress.
50
+ *
51
+ * IMPORTANT: do not call this inside a Prisma `$transaction`. If the outer
52
+ * transaction rolls back after the BullMQ job is added, the worker will pick
53
+ * up an orphaned job pointing at a non-existent RunJob row. Enqueue *after*
54
+ * the transaction commits.
55
+ */
56
+ export async function enqueueRunJob(type, input, options = {}) {
57
+ const runJob = await prisma.runJob.create({
58
+ data: {
59
+ type,
60
+ status: 'PENDING',
61
+ classId: options.classId ?? null,
62
+ userId: options.userId ?? null,
63
+ input: input,
64
+ },
65
+ });
66
+ try {
67
+ const queue = getQueue(type);
68
+ const job = await queue.add(QUEUE_NAMES[type], { runJobId: runJob.id, ...input }, options.jobOptions);
69
+ try {
70
+ await prisma.runJob.update({
71
+ where: { id: runJob.id },
72
+ data: { bullJobId: job.id ?? null },
73
+ });
74
+ }
75
+ catch (linkError) {
76
+ logger.error('RunJob enqueued but bullJobId linkage failed', {
77
+ runJobId: runJob.id,
78
+ bullJobId: job.id ?? null,
79
+ type,
80
+ error: linkError instanceof Error ? linkError.message : String(linkError),
81
+ });
82
+ }
83
+ return { runJobId: runJob.id, bullJobId: job.id ?? null };
84
+ }
85
+ catch (error) {
86
+ logger.error('Failed to enqueue RunJob; marking FAILED', {
87
+ runJobId: runJob.id,
88
+ type,
89
+ error: error instanceof Error ? error.message : String(error),
90
+ });
91
+ try {
92
+ await prisma.runJob.update({
93
+ where: { id: runJob.id },
94
+ data: {
95
+ status: 'FAILED',
96
+ error: error instanceof Error ? error.message : String(error),
97
+ finishedAt: new Date(),
98
+ },
99
+ });
100
+ }
101
+ catch (updateError) {
102
+ logger.error('Failed to mark RunJob as FAILED after enqueue error', {
103
+ runJobId: runJob.id,
104
+ error: updateError instanceof Error ? updateError.message : String(updateError),
105
+ });
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+ /**
111
+ * Close every cached queue. Call from the HTTP server's shutdown handler so
112
+ * the producer side disconnects from Redis cleanly.
113
+ */
114
+ export async function closeAllQueues() {
115
+ await Promise.all(Array.from(queueCache.values()).map((q) => q.close()));
116
+ queueCache.clear();
117
+ }
118
+ //# sourceMappingURL=queues.js.map
119
+ //# debugId=f8634186-ae9e-551f-96ec-058802ec2c99
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queues.js","sources":["workers/queues.ts"],"sourceRoot":"/","sourcesContent":["/**\n * BullMQ queue producers and the `enqueueRunJob` helper.\n *\n * Producer side only — safe to import from HTTP routes / services running on\n * Vercel. Importing this file does NOT start any workers; workers live in\n * `src/workers/processors/*` and are wired up by `src/workers/index.ts`,\n * which is the entrypoint of the separate long-running worker process.\n */\nimport { Queue, type JobsOptions } from 'bullmq';\nimport { Prisma, type RunJobType } from '@prisma/client';\nimport { bullConnection } from './connection.js';\nimport { prisma } from '../lib/prisma.js';\nimport { logger } from '../utils/logger.js';\n\nconst DEFAULT_JOB_OPTIONS: JobsOptions = {\n attempts: 3,\n backoff: { type: 'exponential', delay: 5_000 },\n removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop\n removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection\n};\n\n/**\n * Map RunJobType → (queue name, input payload shape).\n *\n * Add a new entry here whenever you introduce a new background job type.\n * The queue name string must match what the corresponding processor passes\n * to `new Worker(<name>, ...)`.\n */\ntype JobInputs = {\n PDF_TO_WORKSHEET: { gcsUrl: string; classId: string };\n GRADE_WORKSHEET: { worksheetResponseId: string };\n AI_LAB_CHAT: { conversationId: string; messageId: string };\n AI_NEWTON_CHAT: { conversationId: string; messageId: string };\n STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };\n};\n\nconst QUEUE_NAMES: Record<RunJobType, string> = {\n PDF_TO_WORKSHEET: 'pdfToWorksheet',\n GRADE_WORKSHEET: 'gradeWorksheet',\n AI_LAB_CHAT: 'aiLabChat',\n AI_NEWTON_CHAT: 'aiNewtonChat',\n STUDENT_PROGRESS_CHAT: 'studentProgressChat',\n};\n\n/**\n * One Queue instance per type. Created lazily so that importing this module\n * does not eagerly open queues you may never use in a given runtime.\n */\nconst queueCache = new Map<RunJobType, Queue>();\n\nfunction getQueue(type: RunJobType): Queue {\n let queue = queueCache.get(type);\n if (!queue) {\n queue = new Queue(QUEUE_NAMES[type], {\n connection: bullConnection,\n defaultJobOptions: DEFAULT_JOB_OPTIONS,\n });\n queueCache.set(type, queue);\n }\n return queue;\n}\n\nexport interface EnqueueOptions {\n classId?: string | null;\n userId?: string | null;\n /** Override BullMQ job options (attempts, delay, priority, etc.). */\n jobOptions?: JobsOptions;\n}\n\nexport interface EnqueueResult {\n runJobId: string;\n bullJobId: string | null;\n}\n\n/**\n * Create a `RunJob` audit row, enqueue the BullMQ job, and link the two.\n *\n * Returns immediately — the actual work happens on the worker process. The\n * caller can return `runJobId` to the client and have them poll `getRunJob`\n * (or subscribe over Pusher) to track progress.\n *\n * IMPORTANT: do not call this inside a Prisma `$transaction`. If the outer\n * transaction rolls back after the BullMQ job is added, the worker will pick\n * up an orphaned job pointing at a non-existent RunJob row. Enqueue *after*\n * the transaction commits.\n */\nexport async function enqueueRunJob<T extends RunJobType>(\n type: T,\n input: JobInputs[T],\n options: EnqueueOptions = {},\n): Promise<EnqueueResult> {\n const runJob = await prisma.runJob.create({\n data: {\n type,\n status: 'PENDING',\n classId: options.classId ?? null,\n userId: options.userId ?? null,\n input: input as unknown as Prisma.InputJsonValue,\n },\n });\n\n try {\n const queue = getQueue(type);\n const job = await queue.add(\n QUEUE_NAMES[type],\n { runJobId: runJob.id, ...input },\n options.jobOptions,\n );\n\n try {\n await prisma.runJob.update({\n where: { id: runJob.id },\n data: { bullJobId: job.id ?? null },\n });\n } catch (linkError) {\n logger.error('RunJob enqueued but bullJobId linkage failed', {\n runJobId: runJob.id,\n bullJobId: job.id ?? null,\n type,\n error: linkError instanceof Error ? linkError.message : String(linkError),\n });\n }\n\n return { runJobId: runJob.id, bullJobId: job.id ?? null };\n } catch (error) {\n logger.error('Failed to enqueue RunJob; marking FAILED', {\n runJobId: runJob.id,\n type,\n error: error instanceof Error ? error.message : String(error),\n });\n\n try {\n await prisma.runJob.update({\n where: { id: runJob.id },\n data: {\n status: 'FAILED',\n error: error instanceof Error ? error.message : String(error),\n finishedAt: new Date(),\n },\n });\n } catch (updateError) {\n logger.error('Failed to mark RunJob as FAILED after enqueue error', {\n runJobId: runJob.id,\n error: updateError instanceof Error ? updateError.message : String(updateError),\n });\n }\n\n throw error;\n }\n}\n\n/**\n * Close every cached queue. Call from the HTTP server's shutdown handler so\n * the producer side disconnects from Redis cleanly.\n */\nexport async function closeAllQueues(): Promise<void> {\n await Promise.all(Array.from(queueCache.values()).map((q) => q.close()));\n queueCache.clear();\n}\n"],"names":[],"mappings":"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","debug_id":"f8634186-ae9e-551f-96ec-058802ec2c99"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.4.7",
3
+ "version": "1.5.0",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -13,8 +13,10 @@
13
13
  },
14
14
  "scripts": {
15
15
  "dev": "tsx watch src/index.ts",
16
+ "dev:worker": "tsx watch src/workers/index.ts",
16
17
  "build": "tsc && npm run sentry:sourcemaps",
17
18
  "start": "node dist/index.js",
19
+ "start:worker": "node dist/workers/index.js",
18
20
  "generate": "npx prisma generate",
19
21
  "prepublishOnly": "npm run generate && npm run build",
20
22
  "test:pre": "NODE_ENV=test tsx scripts/test-pre-push.ts",
@@ -35,6 +37,7 @@
35
37
  "@trpc/server": "^11.4.3",
36
38
  "@unkey/ratelimit": "^2.1.3",
37
39
  "bcryptjs": "^3.0.2",
40
+ "bullmq": "^5.76.6",
38
41
  "compression": "^1.8.1",
39
42
  "cors": "^2.8.5",
40
43
  "date-fns": "^4.1.0",
@@ -40,7 +40,7 @@ CREATE TABLE "SchoolInvite" (
40
40
 
41
41
  INSERT INTO "SchoolMembership" ("id", "schoolId", "userId", "role", "status", "createdAt", "updatedAt")
42
42
  SELECT
43
- "id",
43
+ gen_random_uuid()::text,
44
44
  "schoolId",
45
45
  "id",
46
46
  CASE
@@ -0,0 +1,166 @@
1
+ -- Consolidate background-job tracking onto RunJob and unify domain status.
2
+ --
3
+ -- 1. Replace the bare-bones RunJob table with a fully-typed audit row that
4
+ -- carries job type, status, input/result, error, attempts, timestamps.
5
+ -- 2. Replace the catch-all GenerationStatus enum with a single shared
6
+ -- JobStatus enum used by every domain row that has a user-observable
7
+ -- async lifecycle (Message, StudentQuestionProgress).
8
+ -- 3. Add a nullable runJobId FK on Message and StudentQuestionProgress so we
9
+ -- can trace a domain row back to the job that produced it. Operational
10
+ -- state (retries, errors, attempts) lives on RunJob, not on the row.
11
+ -- 4. Drop the unused Comment.status column.
12
+
13
+ -- ---------------------------------------------------------------------------
14
+ -- 1. New enum types
15
+ -- ---------------------------------------------------------------------------
16
+
17
+ CREATE TYPE "RunJobType" AS ENUM (
18
+ 'PDF_TO_WORKSHEET',
19
+ 'GRADE_WORKSHEET',
20
+ 'AI_LAB_CHAT',
21
+ 'AI_NEWTON_CHAT',
22
+ 'STUDENT_PROGRESS_CHAT'
23
+ );
24
+
25
+ -- Queue-internal lifecycle. Distinct from JobStatus because PENDING (queued
26
+ -- but not picked up) vs RUNNING (worker actively executing) is a real
27
+ -- operational distinction we want when debugging.
28
+ CREATE TYPE "RunJobStatus" AS ENUM (
29
+ 'PENDING',
30
+ 'RUNNING',
31
+ 'SUCCEEDED',
32
+ 'FAILED',
33
+ 'CANCELLED'
34
+ );
35
+
36
+ -- Shared UI-shaped status for domain rows. Not every row uses every value
37
+ -- (e.g. Message never gets NOT_STARTED) but we keep one enum to avoid an
38
+ -- explosion of near-duplicate types.
39
+ CREATE TYPE "JobStatus" AS ENUM (
40
+ 'NOT_STARTED',
41
+ 'PENDING',
42
+ 'COMPLETED',
43
+ 'FAILED',
44
+ 'CANCELLED'
45
+ );
46
+
47
+ -- ---------------------------------------------------------------------------
48
+ -- 2. Replace the legacy RunJob table.
49
+ --
50
+ -- The previous schema only had `id` and `createdAt`, and was applied via
51
+ -- `prisma db push` (no migration history) — so any rows in it are throwaway.
52
+ -- We DROP IF EXISTS to handle both fresh production DBs and dev DBs that have
53
+ -- the old shape.
54
+ -- ---------------------------------------------------------------------------
55
+
56
+ DROP TABLE IF EXISTS "RunJob" CASCADE;
57
+
58
+ CREATE TABLE "RunJob" (
59
+ "id" TEXT NOT NULL,
60
+ "type" "RunJobType" NOT NULL,
61
+ "status" "RunJobStatus" NOT NULL DEFAULT 'PENDING',
62
+ "classId" TEXT,
63
+ "userId" TEXT,
64
+ "bullJobId" TEXT,
65
+ "input" JSONB NOT NULL,
66
+ "result" JSONB,
67
+ "error" TEXT,
68
+ "attempts" INTEGER NOT NULL DEFAULT 0,
69
+ "startedAt" TIMESTAMP(3),
70
+ "finishedAt" TIMESTAMP(3),
71
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ "updatedAt" TIMESTAMP(3) NOT NULL,
73
+
74
+ CONSTRAINT "RunJob_pkey" PRIMARY KEY ("id")
75
+ );
76
+
77
+ CREATE INDEX "RunJob_userId_type_status_idx" ON "RunJob"("userId", "type", "status");
78
+ CREATE INDEX "RunJob_classId_status_idx" ON "RunJob"("classId", "status");
79
+ CREATE INDEX "RunJob_status_createdAt_idx" ON "RunJob"("status", "createdAt");
80
+
81
+ -- ---------------------------------------------------------------------------
82
+ -- 2b. Track PDF source URLs on worksheets for idempotent PDF conversion.
83
+ -- ---------------------------------------------------------------------------
84
+
85
+ ALTER TABLE "Worksheet" ADD COLUMN "sourceUrl" TEXT;
86
+
87
+ CREATE UNIQUE INDEX "Worksheet_classId_sourceUrl_key"
88
+ ON "Worksheet"("classId", "sourceUrl");
89
+
90
+ -- ---------------------------------------------------------------------------
91
+ -- 3. Message.status: GenerationStatus -> JobStatus.
92
+ --
93
+ -- Old enum had NOT_STARTED / PENDING / GENERATING / COMPLETED / FAILED /
94
+ -- CANCELLED. New JobStatus drops GENERATING (never written). The old default
95
+ -- of NOT_STARTED is dropped: a row without an AI lifecycle now has NULL.
96
+ -- GENERATING is mapped to PENDING defensively in case any row carries it.
97
+ -- ---------------------------------------------------------------------------
98
+
99
+ ALTER TABLE "Message" ALTER COLUMN "status" DROP DEFAULT;
100
+
101
+ ALTER TABLE "Message"
102
+ ALTER COLUMN "status" TYPE "JobStatus"
103
+ USING (
104
+ CASE "status"::text
105
+ WHEN 'NOT_STARTED' THEN NULL
106
+ WHEN 'GENERATING' THEN 'PENDING'::"JobStatus"
107
+ WHEN 'PENDING' THEN 'PENDING'::"JobStatus"
108
+ WHEN 'COMPLETED' THEN 'COMPLETED'::"JobStatus"
109
+ WHEN 'FAILED' THEN 'FAILED'::"JobStatus"
110
+ WHEN 'CANCELLED' THEN 'CANCELLED'::"JobStatus"
111
+ ELSE NULL
112
+ END
113
+ );
114
+
115
+ -- ---------------------------------------------------------------------------
116
+ -- 4. StudentQuestionProgress.status: GenerationStatus -> JobStatus.
117
+ --
118
+ -- Default kept at NOT_STARTED. GENERATING -> PENDING defensively.
119
+ -- ---------------------------------------------------------------------------
120
+
121
+ ALTER TABLE "StudentQuestionProgress" ALTER COLUMN "status" DROP DEFAULT;
122
+
123
+ ALTER TABLE "StudentQuestionProgress"
124
+ ALTER COLUMN "status" TYPE "JobStatus"
125
+ USING (
126
+ CASE "status"::text
127
+ WHEN 'NOT_STARTED' THEN 'NOT_STARTED'::"JobStatus"
128
+ WHEN 'PENDING' THEN 'PENDING'::"JobStatus"
129
+ WHEN 'GENERATING' THEN 'PENDING'::"JobStatus"
130
+ WHEN 'COMPLETED' THEN 'COMPLETED'::"JobStatus"
131
+ WHEN 'FAILED' THEN 'FAILED'::"JobStatus"
132
+ WHEN 'CANCELLED' THEN 'CANCELLED'::"JobStatus"
133
+ ELSE NULL
134
+ END
135
+ );
136
+
137
+ ALTER TABLE "StudentQuestionProgress"
138
+ ALTER COLUMN "status" SET DEFAULT 'NOT_STARTED';
139
+
140
+ -- ---------------------------------------------------------------------------
141
+ -- 5. Drop the unused Comment.status column and the legacy enum.
142
+ -- ---------------------------------------------------------------------------
143
+
144
+ ALTER TABLE "Comment" DROP COLUMN "status";
145
+
146
+ DROP TYPE "GenerationStatus";
147
+
148
+ -- ---------------------------------------------------------------------------
149
+ -- 6. Add runJobId FKs and supporting indexes.
150
+ -- ---------------------------------------------------------------------------
151
+
152
+ ALTER TABLE "Message"
153
+ ADD COLUMN "runJobId" TEXT,
154
+ ADD CONSTRAINT "Message_runJobId_fkey"
155
+ FOREIGN KEY ("runJobId") REFERENCES "RunJob"("id")
156
+ ON DELETE SET NULL ON UPDATE CASCADE;
157
+
158
+ CREATE INDEX "Message_runJobId_idx" ON "Message"("runJobId");
159
+
160
+ ALTER TABLE "StudentQuestionProgress"
161
+ ADD COLUMN "runJobId" TEXT,
162
+ ADD CONSTRAINT "StudentQuestionProgress_runJobId_fkey"
163
+ FOREIGN KEY ("runJobId") REFERENCES "RunJob"("id")
164
+ ON DELETE SET NULL ON UPDATE CASCADE;
165
+
166
+ CREATE INDEX "StudentQuestionProgress_runJobId_idx" ON "StudentQuestionProgress"("runJobId");