@studious-lms/server 1.5.0 → 1.7.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 (141) hide show
  1. package/dist/index.js +15 -3
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/config/cors.d.ts.map +1 -1
  4. package/dist/lib/config/cors.js +3 -2
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/lib/pusher.d.ts +7 -1
  7. package/dist/lib/pusher.d.ts.map +1 -1
  8. package/dist/lib/pusher.js +9 -3
  9. package/dist/lib/pusher.js.map +1 -1
  10. package/dist/models/attendance.d.ts +16 -7
  11. package/dist/models/attendance.d.ts.map +1 -1
  12. package/dist/models/attendance.js +17 -2
  13. package/dist/models/attendance.js.map +1 -1
  14. package/dist/models/conversation.d.ts +33 -17
  15. package/dist/models/conversation.d.ts.map +1 -1
  16. package/dist/models/conversation.js +50 -2
  17. package/dist/models/conversation.js.map +1 -1
  18. package/dist/pipelines/aiLabChat.d.ts +20 -9
  19. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  20. package/dist/pipelines/aiLabChat.js +62 -74
  21. package/dist/pipelines/aiLabChat.js.map +1 -1
  22. package/dist/pipelines/aiNewtonChat.d.ts +13 -4
  23. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.js +39 -27
  25. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  26. package/dist/pipelines/pdfToWorksheet.d.ts +31 -2
  27. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  28. package/dist/pipelines/pdfToWorksheet.js +160 -28
  29. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  30. package/dist/routers/_app.d.ts +48 -4
  31. package/dist/routers/_app.d.ts.map +1 -1
  32. package/dist/routers/assignment.d.ts.map +1 -1
  33. package/dist/routers/assignment.js +64 -4
  34. package/dist/routers/assignment.js.map +1 -1
  35. package/dist/routers/conversation.d.ts +15 -0
  36. package/dist/routers/conversation.d.ts.map +1 -1
  37. package/dist/routers/conversation.js +6 -3
  38. package/dist/routers/conversation.js.map +1 -1
  39. package/dist/routers/labChat.d.ts +1 -0
  40. package/dist/routers/labChat.d.ts.map +1 -1
  41. package/dist/routers/message.d.ts +3 -0
  42. package/dist/routers/message.d.ts.map +1 -1
  43. package/dist/routers/newtonChat.d.ts +1 -0
  44. package/dist/routers/newtonChat.d.ts.map +1 -1
  45. package/dist/routers/worksheet.d.ts +4 -2
  46. package/dist/routers/worksheet.d.ts.map +1 -1
  47. package/dist/routers/worksheet.js +16 -6
  48. package/dist/routers/worksheet.js.map +1 -1
  49. package/dist/services/announcement.js +4 -4
  50. package/dist/services/announcement.js.map +1 -1
  51. package/dist/services/assignment.d.ts.map +1 -1
  52. package/dist/services/assignment.js +16 -6
  53. package/dist/services/assignment.js.map +1 -1
  54. package/dist/services/attendance.d.ts.map +1 -1
  55. package/dist/services/attendance.js +68 -4
  56. package/dist/services/attendance.js.map +1 -1
  57. package/dist/services/conversation.d.ts +16 -0
  58. package/dist/services/conversation.d.ts.map +1 -1
  59. package/dist/services/conversation.js +22 -3
  60. package/dist/services/conversation.js.map +1 -1
  61. package/dist/services/labChat.d.ts +8 -1
  62. package/dist/services/labChat.d.ts.map +1 -1
  63. package/dist/services/labChat.js +101 -42
  64. package/dist/services/labChat.js.map +1 -1
  65. package/dist/services/message.d.ts +3 -0
  66. package/dist/services/message.d.ts.map +1 -1
  67. package/dist/services/message.js +8 -2
  68. package/dist/services/message.js.map +1 -1
  69. package/dist/services/newtonChat.d.ts +1 -0
  70. package/dist/services/newtonChat.d.ts.map +1 -1
  71. package/dist/services/newtonChat.js +40 -17
  72. package/dist/services/newtonChat.js.map +1 -1
  73. package/dist/services/worksheet.d.ts +15 -6
  74. package/dist/services/worksheet.d.ts.map +1 -1
  75. package/dist/services/worksheet.js +58 -12
  76. package/dist/services/worksheet.js.map +1 -1
  77. package/dist/utils/inference.d.ts +50 -1
  78. package/dist/utils/inference.d.ts.map +1 -1
  79. package/dist/utils/inference.js +167 -2
  80. package/dist/utils/inference.js.map +1 -1
  81. package/dist/workers/index.js +13 -3
  82. package/dist/workers/index.js.map +1 -1
  83. package/dist/workers/processors/aiLabChat.d.ts +7 -0
  84. package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
  85. package/dist/workers/processors/aiLabChat.js +52 -0
  86. package/dist/workers/processors/aiLabChat.js.map +1 -0
  87. package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
  88. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
  89. package/dist/workers/processors/aiNewtonChat.js +57 -0
  90. package/dist/workers/processors/aiNewtonChat.js.map +1 -0
  91. package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
  92. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
  93. package/dist/workers/processors/createRunJobWorker.js +118 -0
  94. package/dist/workers/processors/createRunJobWorker.js.map +1 -0
  95. package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
  96. package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
  97. package/dist/workers/processors/gradeWorksheet.js +19 -0
  98. package/dist/workers/processors/gradeWorksheet.js.map +1 -0
  99. package/dist/workers/processors/pdfToWorksheet.d.ts +5 -6
  100. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  101. package/dist/workers/processors/pdfToWorksheet.js +15 -95
  102. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  103. package/dist/workers/processors/studentProgressChat.d.ts +8 -0
  104. package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
  105. package/dist/workers/processors/studentProgressChat.js +27 -0
  106. package/dist/workers/processors/studentProgressChat.js.map +1 -0
  107. package/dist/workers/queues.d.ts +20 -4
  108. package/dist/workers/queues.d.ts.map +1 -1
  109. package/dist/workers/queues.js +3 -3
  110. package/dist/workers/queues.js.map +1 -1
  111. package/package.json +3 -3
  112. package/src/index.ts +12 -1
  113. package/src/lib/config/cors.ts +1 -0
  114. package/src/lib/pusher.ts +7 -1
  115. package/src/models/attendance.ts +15 -0
  116. package/src/models/conversation.ts +54 -0
  117. package/src/pipelines/aiLabChat.ts +70 -78
  118. package/src/pipelines/aiNewtonChat.ts +47 -31
  119. package/src/pipelines/pdfToWorksheet.ts +224 -32
  120. package/src/routers/assignment.ts +74 -3
  121. package/src/routers/conversation.ts +7 -0
  122. package/src/routers/worksheet.ts +25 -4
  123. package/src/services/announcement.ts +2 -2
  124. package/src/services/assignment.ts +17 -4
  125. package/src/services/attendance.ts +87 -2
  126. package/src/services/conversation.ts +30 -0
  127. package/src/services/labChat.ts +106 -46
  128. package/src/services/message.ts +6 -0
  129. package/src/services/newtonChat.ts +45 -25
  130. package/src/services/worksheet.ts +71 -10
  131. package/src/utils/inference.ts +209 -1
  132. package/src/workers/index.ts +11 -1
  133. package/src/workers/processors/aiLabChat.ts +65 -0
  134. package/src/workers/processors/aiNewtonChat.ts +75 -0
  135. package/src/workers/processors/createRunJobWorker.ts +164 -0
  136. package/src/workers/processors/gradeWorksheet.ts +16 -0
  137. package/src/workers/processors/pdfToWorksheet.ts +12 -114
  138. package/src/workers/processors/studentProgressChat.ts +28 -0
  139. package/src/workers/queues.ts +20 -5
  140. package/tests/integration/routers/attendance.test.ts +60 -1
  141. package/tests/integration/routers/conversation.test.ts +121 -2
@@ -1,100 +1,20 @@
1
1
 
2
- !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]="1b13378b-3952-5f91-b138-a69c3693a56d")}catch(e){}}();
3
- import { Worker } from 'bullmq';
4
- import { Prisma } from '@prisma/client';
5
- import { bullConnection } from '../connection.js';
6
- import { prisma } from '../../lib/prisma.js';
7
- import { logger } from '../../utils/logger.js';
2
+ !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]="3beb4788-6c75-5aae-b6c1-3195cbdeffa3")}catch(e){}}();
8
3
  import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';
9
- async function process(job) {
10
- const { runJobId, gcsUrl, classId } = job.data;
11
- // If a previous attempt already succeeded for this RunJob (rare race after a
12
- // crash that lost the BullMQ ack), return the prior result instead of
13
- // re-running the AI call and creating a duplicate worksheet.
14
- const existing = await prisma.runJob.findUnique({
15
- where: { id: runJobId },
16
- select: { status: true, result: true },
17
- });
18
- if (existing?.status === 'SUCCEEDED') {
19
- logger.info('pdfToWorksheet RunJob already succeeded; skipping re-run', {
20
- runJobId,
21
- bullJobId: job.id,
22
- });
23
- return existing.result;
24
- }
25
- await prisma.runJob.update({
26
- where: { id: runJobId },
27
- data: {
28
- status: 'RUNNING',
29
- startedAt: existing?.status === 'RUNNING' ? undefined : new Date(),
30
- attempts: { increment: 1 },
31
- },
32
- });
33
- const worksheet = await pdfToWorksheet(gcsUrl, classId);
34
- const result = worksheet ? { worksheetId: worksheet.id, name: worksheet.name } : null;
35
- await prisma.runJob.update({
36
- where: { id: runJobId },
37
- data: {
38
- status: 'SUCCEEDED',
39
- finishedAt: new Date(),
40
- error: null,
41
- result: result === null ? Prisma.JsonNull : result,
42
- },
43
- });
44
- return result;
45
- }
46
- export const pdfToWorksheetWorker = new Worker('pdfToWorksheet', process, {
47
- connection: bullConnection,
48
- // Conservative defaults — tune once we have real-world numbers.
49
- concurrency: 2,
50
- limiter: {
51
- max: 5,
52
- duration: 60000, // 5 jobs / minute, e.g. to stay under OpenAI rate limits
4
+ import { createRunJobWorker } from './createRunJobWorker.js';
5
+ export const pdfToWorksheetWorker = createRunJobWorker({
6
+ type: 'PDF_TO_WORKSHEET',
7
+ workerOptions: {
8
+ concurrency: 2,
9
+ // 5 jobs / minute, e.g. to stay under OpenAI rate limits.
10
+ limiter: { max: 5, duration: 60000 },
11
+ },
12
+ run: async ({ gcsUrl, classId, worksheetId }) => {
13
+ const worksheet = await pdfToWorksheet(gcsUrl, classId, { worksheetId });
14
+ return worksheet
15
+ ? { worksheetId: worksheet.id, name: worksheet.name }
16
+ : null;
53
17
  },
54
- });
55
- pdfToWorksheetWorker.on('error', (err) => {
56
- logger.error('pdfToWorksheet worker error', {
57
- error: err.message,
58
- stack: err.stack,
59
- });
60
- });
61
- pdfToWorksheetWorker.on('failed', async (job, err) => {
62
- if (!job)
63
- return;
64
- const runJobId = job.data?.runJobId;
65
- const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? 1);
66
- logger.error('pdfToWorksheet job failed', {
67
- bullJobId: job.id,
68
- runJobId,
69
- attempt: job.attemptsMade,
70
- finalAttempt: isFinalAttempt,
71
- error: err.message,
72
- });
73
- if (!runJobId)
74
- return;
75
- // Always record the latest error message on the RunJob row.
76
- // Only flip to FAILED on the final attempt — earlier failures are still
77
- // "RUNNING" because BullMQ is going to retry.
78
- await prisma.runJob.update({
79
- where: { id: runJobId },
80
- data: {
81
- error: err.message,
82
- ...(isFinalAttempt
83
- ? { status: 'FAILED', finishedAt: new Date() }
84
- : {}),
85
- },
86
- }).catch((updateError) => {
87
- logger.error('Failed to record pdfToWorksheet failure on RunJob', {
88
- runJobId,
89
- error: updateError instanceof Error ? updateError.message : String(updateError),
90
- });
91
- });
92
- });
93
- pdfToWorksheetWorker.on('completed', (job) => {
94
- logger.info('pdfToWorksheet job completed', {
95
- bullJobId: job.id,
96
- runJobId: job.data?.runJobId,
97
- });
98
18
  });
99
19
  //# sourceMappingURL=pdfToWorksheet.js.map
100
- //# debugId=1b13378b-3952-5f91-b138-a69c3693a56d
20
+ //# debugId=3beb4788-6c75-5aae-b6c1-3195cbdeffa3
@@ -1 +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"}
1
+ {"version":3,"file":"pdfToWorksheet.js","sources":["workers/processors/pdfToWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const pdfToWorksheetWorker = createRunJobWorker({\n type: 'PDF_TO_WORKSHEET',\n workerOptions: {\n concurrency: 2,\n // 5 jobs / minute, e.g. to stay under OpenAI rate limits.\n limiter: { max: 5, duration: 60_000 },\n },\n run: async ({ gcsUrl, classId, worksheetId }) => {\n const worksheet = await pdfToWorksheet(gcsUrl, classId, { worksheetId });\n return worksheet\n ? { worksheetId: worksheet.id, name: worksheet.name }\n : null;\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;IACrD,IAAI,EAAE,kBAAkB;IACxB,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,0DAA0D;QAC1D,OAAO,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAM,EAAE;KACtC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EAAE;QAC9C,MAAM,SAAS,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC;QACzE,OAAO,SAAS;YACd,CAAC,CAAC,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE;YACrD,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;CACF,CAAC,CAAC","debug_id":"3beb4788-6c75-5aae-b6c1-3195cbdeffa3"}
@@ -0,0 +1,8 @@
1
+ export declare const studentProgressChatWorker: import("bullmq").Worker<{
2
+ classId: string;
3
+ studentId: string;
4
+ message: string;
5
+ } & {
6
+ runJobId: string;
7
+ }, any, string>;
8
+ //# sourceMappingURL=studentProgressChat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"studentProgressChat.d.ts","sourceRoot":"/","sources":["workers/processors/studentProgressChat.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,yBAAyB;;;;;;eAwBpC,CAAC"}
@@ -0,0 +1,27 @@
1
+
2
+ !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]="13740d05-3496-50cc-a32d-baa98cef5e0b")}catch(e){}}();
3
+ import { chatAboutStudentProgress } from '../../services/studentProgress.js';
4
+ import { createRunJobWorker } from './createRunJobWorker.js';
5
+ export const studentProgressChatWorker = createRunJobWorker({
6
+ type: 'STUDENT_PROGRESS_CHAT',
7
+ workerOptions: {
8
+ concurrency: 4,
9
+ limiter: { max: 20, duration: 60000 },
10
+ },
11
+ run: async ({ classId, studentId, message }, ctx) => {
12
+ // `chatAboutStudentProgress` runs an authorization check using the viewer
13
+ // id, so callers MUST pass `userId` via `enqueueRunJob` options for the
14
+ // service to receive it here.
15
+ if (!ctx.userId) {
16
+ throw new Error('STUDENT_PROGRESS_CHAT requires `userId` on the RunJob (set via enqueueRunJob options)');
17
+ }
18
+ const response = await chatAboutStudentProgress(ctx.userId, {
19
+ classId,
20
+ studentId,
21
+ message,
22
+ });
23
+ return response;
24
+ },
25
+ });
26
+ //# sourceMappingURL=studentProgressChat.js.map
27
+ //# debugId=13740d05-3496-50cc-a32d-baa98cef5e0b
@@ -0,0 +1 @@
1
+ {"version":3,"file":"studentProgressChat.js","sources":["workers/processors/studentProgressChat.ts"],"sourceRoot":"/","sourcesContent":["import { chatAboutStudentProgress } from '../../services/studentProgress.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const studentProgressChatWorker = createRunJobWorker({\n type: 'STUDENT_PROGRESS_CHAT',\n workerOptions: {\n concurrency: 4,\n limiter: { max: 20, duration: 60_000 },\n },\n run: async ({ classId, studentId, message }, ctx) => {\n // `chatAboutStudentProgress` runs an authorization check using the viewer\n // id, so callers MUST pass `userId` via `enqueueRunJob` options for the\n // service to receive it here.\n if (!ctx.userId) {\n throw new Error(\n 'STUDENT_PROGRESS_CHAT requires `userId` on the RunJob (set via enqueueRunJob options)',\n );\n }\n\n const response = await chatAboutStudentProgress(ctx.userId, {\n classId,\n studentId,\n message,\n });\n\n return response;\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,yBAAyB,GAAG,kBAAkB,CAAC;IAC1D,IAAI,EAAE,uBAAuB;IAC7B,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAM,EAAE;KACvC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE;QAClD,0EAA0E;QAC1E,wEAAwE;QACxE,8BAA8B;QAC9B,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CACb,uFAAuF,CACxF,CAAC;QACJ,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,wBAAwB,CAAC,GAAG,CAAC,MAAM,EAAE;YAC1D,OAAO;YACP,SAAS;YACT,OAAO;SACR,CAAC,CAAC;QAEH,OAAO,QAAQ,CAAC;IAClB,CAAC;CACF,CAAC,CAAC","debug_id":"13740d05-3496-50cc-a32d-baa98cef5e0b"}
@@ -15,21 +15,37 @@ import { type RunJobType } from '@prisma/client';
15
15
  * The queue name string must match what the corresponding processor passes
16
16
  * to `new Worker(<name>, ...)`.
17
17
  */
18
- type JobInputs = {
18
+ export type JobInputs = {
19
+ /**
20
+ * `worksheetId` is the placeholder Worksheet row created synchronously by
21
+ * the producer (so the client has an id to navigate to). The pipeline fills
22
+ * that row in asynchronously and broadcasts progress on the per-worksheet
23
+ * job channel.
24
+ */
19
25
  PDF_TO_WORKSHEET: {
20
26
  gcsUrl: string;
21
27
  classId: string;
28
+ worksheetId: string;
22
29
  };
23
30
  GRADE_WORKSHEET: {
24
31
  worksheetResponseId: string;
25
32
  };
33
+ /**
34
+ * `aiMessageId` is the placeholder AI Message row created synchronously by
35
+ * the producer before enqueue (so the client renders a PENDING bubble in
36
+ * the timeline). The pipeline fills in the content and flips the row to
37
+ * COMPLETED / FAILED. Used for both intros and replies — single shape.
38
+ */
26
39
  AI_LAB_CHAT: {
27
40
  conversationId: string;
28
- messageId: string;
41
+ aiMessageId: string;
29
42
  };
43
+ /**
44
+ * `aiMessageId` placeholder pattern, same as AI_LAB_CHAT — see above.
45
+ */
30
46
  AI_NEWTON_CHAT: {
31
47
  conversationId: string;
32
- messageId: string;
48
+ aiMessageId: string;
33
49
  };
34
50
  STUDENT_PROGRESS_CHAT: {
35
51
  classId: string;
@@ -37,6 +53,7 @@ type JobInputs = {
37
53
  message: string;
38
54
  };
39
55
  };
56
+ export declare const QUEUE_NAMES: Record<RunJobType, string>;
40
57
  export interface EnqueueOptions {
41
58
  classId?: string | null;
42
59
  userId?: string | null;
@@ -65,5 +82,4 @@ export declare function enqueueRunJob<T extends RunJobType>(type: T, input: JobI
65
82
  * the producer side disconnects from Redis cleanly.
66
83
  */
67
84
  export declare function closeAllQueues(): Promise<void>;
68
- export {};
69
85
  //# sourceMappingURL=queues.d.ts.map
@@ -1 +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"}
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,MAAM,MAAM,SAAS,GAAG;IACtB;;;;;OAKG;IACH,gBAAgB,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,eAAe,EAAE;QAAE,mBAAmB,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD;;;;;OAKG;IACH,WAAW,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D;;OAEG;IACH,cAAc,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,qBAAqB,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAMlD,CAAC;AAoBF,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"}
@@ -7,7 +7,7 @@
7
7
  * which is the entrypoint of the separate long-running worker process.
8
8
  */
9
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){}}();
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]="feefd10a-9381-5c30-a525-0ffe36e2e323")}catch(e){}}();
11
11
  import { Queue } from 'bullmq';
12
12
  import { bullConnection } from './connection.js';
13
13
  import { prisma } from '../lib/prisma.js';
@@ -18,7 +18,7 @@ const DEFAULT_JOB_OPTIONS = {
18
18
  removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop
19
19
  removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection
20
20
  };
21
- const QUEUE_NAMES = {
21
+ export const QUEUE_NAMES = {
22
22
  PDF_TO_WORKSHEET: 'pdfToWorksheet',
23
23
  GRADE_WORKSHEET: 'gradeWorksheet',
24
24
  AI_LAB_CHAT: 'aiLabChat',
@@ -116,4 +116,4 @@ export async function closeAllQueues() {
116
116
  queueCache.clear();
117
117
  }
118
118
  //# sourceMappingURL=queues.js.map
119
- //# debugId=f8634186-ae9e-551f-96ec-058802ec2c99
119
+ //# debugId=feefd10a-9381-5c30-a525-0ffe36e2e323
@@ -1 +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":"AAAA;;;;;;;GAOG;;;AACH,OAAO,EAAE,KAAK,EAAoB,MAAM,QAAQ,CAAC;AAEjD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,mBAAmB,GAAgB;IACvC,QAAQ,EAAE,CAAC;IACX,OAAO,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,IAAK,EAAE;IAC9C,gBAAgB,EAAE,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,iDAAiD;IACrF,YAAY,EAAE,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,0CAA0C;CAChF,CAAC;AAiBF,MAAM,WAAW,GAA+B;IAC9C,gBAAgB,EAAE,gBAAgB;IAClC,eAAe,EAAE,gBAAgB;IACjC,WAAW,EAAE,WAAW;IACxB,cAAc,EAAE,cAAc;IAC9B,qBAAqB,EAAE,qBAAqB;CAC7C,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,EAAqB,CAAC;AAEhD,SAAS,QAAQ,CAAC,IAAgB;IAChC,IAAI,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,KAAK,GAAG,IAAI,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE;YACnC,UAAU,EAAE,cAAc;YAC1B,iBAAiB,EAAE,mBAAmB;SACvC,CAAC,CAAC;QACH,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAcD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAO,EACP,KAAmB,EACnB,UAA0B,EAAE;IAE5B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;QACxC,IAAI,EAAE;YACJ,IAAI;YACJ,MAAM,EAAE,SAAS;YACjB,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,IAAI;YAChC,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI;YAC9B,KAAK,EAAE,KAAyC;SACjD;KACF,CAAC,CAAC;IAEH,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC7B,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,CACzB,WAAW,CAAC,IAAI,CAAC,EACjB,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,GAAG,KAAK,EAAE,EACjC,OAAO,CAAC,UAAU,CACnB,CAAC;QAEF,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;gBACzB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;gBACxB,IAAI,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,IAAI,IAAI,EAAE;aACpC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,SAAS,EAAE,CAAC;YACnB,MAAM,CAAC,KAAK,CAAC,8CAA8C,EAAE;gBAC3D,QAAQ,EAAE,MAAM,CAAC,EAAE;gBACnB,SAAS,EAAE,GAAG,CAAC,EAAE,IAAI,IAAI;gBACzB,IAAI;gBACJ,KAAK,EAAE,SAAS,YAAY,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC;aAC1E,CAAC,CAAC;QACL,CAAC;QAED,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,IAAI,IAAI,EAAE,CAAC;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,CAAC,KAAK,CAAC,0CAA0C,EAAE;YACvD,QAAQ,EAAE,MAAM,CAAC,EAAE;YACnB,IAAI;YACJ,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC9D,CAAC,CAAC;QAEH,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;gBACzB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;gBACxB,IAAI,EAAE;oBACJ,MAAM,EAAE,QAAQ;oBAChB,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;oBAC7D,UAAU,EAAE,IAAI,IAAI,EAAE;iBACvB;aACF,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,WAAW,EAAE,CAAC;YACrB,MAAM,CAAC,KAAK,CAAC,qDAAqD,EAAE;gBAClE,QAAQ,EAAE,MAAM,CAAC,EAAE;gBACnB,KAAK,EAAE,WAAW,YAAY,KAAK,CAAC,CAAC,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC;aAChF,CAAC,CAAC;QACL,CAAC;QAED,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc;IAClC,MAAM,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACzE,UAAU,CAAC,KAAK,EAAE,CAAC;AACrB,CAAC","debug_id":"f8634186-ae9e-551f-96ec-058802ec2c99"}
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 */\nexport type JobInputs = {\n /**\n * `worksheetId` is the placeholder Worksheet row created synchronously by\n * the producer (so the client has an id to navigate to). The pipeline fills\n * that row in asynchronously and broadcasts progress on the per-worksheet\n * job channel.\n */\n PDF_TO_WORKSHEET: { gcsUrl: string; classId: string; worksheetId: string };\n GRADE_WORKSHEET: { worksheetResponseId: string };\n /**\n * `aiMessageId` is the placeholder AI Message row created synchronously by\n * the producer before enqueue (so the client renders a PENDING bubble in\n * the timeline). The pipeline fills in the content and flips the row to\n * COMPLETED / FAILED. Used for both intros and replies — single shape.\n */\n AI_LAB_CHAT: { conversationId: string; aiMessageId: string };\n /**\n * `aiMessageId` placeholder pattern, same as AI_LAB_CHAT — see above.\n */\n AI_NEWTON_CHAT: { conversationId: string; aiMessageId: string };\n STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };\n};\n\nexport const 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":"feefd10a-9381-5c30-a525-0ffe36e2e323"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -29,7 +29,7 @@
29
29
  "sentry:sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload --org \"${SENTRY_ORG:-studious-lms}\" --project \"${SENTRY_PROJECT:-server}\" ./dist"
30
30
  },
31
31
  "dependencies": {
32
- "@google-cloud/storage": "^7.16.0",
32
+ "@google-cloud/storage": "^5.18.3",
33
33
  "@pdf-lib/fontkit": "^1.1.1",
34
34
  "@prisma/client": "^6.7.0",
35
35
  "@sentry/cli": "^2.58.2",
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^8.2.1",
47
47
  "helmet": "^8.1.0",
48
48
  "ioredis": "^5.10.0",
49
- "nodemailer": "^7.0.4",
49
+ "nodemailer": "^8.0.7",
50
50
  "openai": "^5.23.0",
51
51
  "pdf-lib": "^1.17.1",
52
52
  "prisma": "^6.7.0",
package/src/index.ts CHANGED
@@ -187,7 +187,18 @@ app.post('/api/pusher/auth', async (req, res) => {
187
187
  }
188
188
  }
189
189
 
190
- if (channel_name.startsWith('private-worksheet-')) {
190
+ if (channel_name.startsWith('private-worksheet-job-')) {
191
+ const worksheetId = channel_name.replace('private-worksheet-job-', '');
192
+ const worksheet = await prisma.worksheet.findFirst({
193
+ where: {
194
+ id: worksheetId,
195
+ class: { teachers: { some: { id: user.id } } },
196
+ },
197
+ });
198
+ if (!worksheet) {
199
+ return res.status(403).json({ error: 'No access to this worksheet job' });
200
+ }
201
+ } else if (channel_name.startsWith('private-worksheet-')) {
191
202
  const worksheetId = channel_name.replace('private-worksheet-', '');
192
203
  const worksheet = await prisma.studentWorksheetResponse.findFirst({
193
204
  where: { id: worksheetId, OR: [
@@ -8,6 +8,7 @@ type CorsEnv = {
8
8
  const productionOrigins = [
9
9
  'https://www.studious.sh',
10
10
  'https://studious.sh',
11
+ 'https://dev.studious.sh',
11
12
  ];
12
13
 
13
14
  const nonProductionOrigins = [
package/src/lib/pusher.ts CHANGED
@@ -12,5 +12,11 @@ const pusher = new Pusher({
12
12
  const chatChannel = (conversationId: string) => `private-conversation-${conversationId}`;
13
13
  const worksheetChannel = (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`;
14
14
  const teacherChannel = (classId: string) => `private-teacher-${classId}`;
15
+ /**
16
+ * Per-worksheet job channel used for asynchronous worksheet creation
17
+ * (e.g. PDF → worksheet conversion). Distinct prefix from the student
18
+ * worksheet-response channel so both can co-exist without auth collisions.
19
+ */
20
+ const worksheetJobChannel = (worksheetId: string) => `private-worksheet-job-${worksheetId}`;
15
21
 
16
- export { pusher, chatChannel, worksheetChannel, teacherChannel };
22
+ export { pusher, chatChannel, worksheetChannel, teacherChannel, worksheetJobChannel };
@@ -113,6 +113,11 @@ export function findClassWithTeacher(classId: string, teacherId: string) {
113
113
  id: classId,
114
114
  ...classTeacherOrSchoolAdminWhere(teacherId),
115
115
  },
116
+ select: {
117
+ id: true,
118
+ name: true,
119
+ students: { select: { id: true } },
120
+ },
116
121
  });
117
122
  }
118
123
 
@@ -137,6 +142,16 @@ export function findAttendanceByClassAndEvent(classId: string, eventId: string |
137
142
  classId,
138
143
  eventId,
139
144
  },
145
+ include: {
146
+ event: {
147
+ select: {
148
+ id: true,
149
+ name: true,
150
+ },
151
+ },
152
+ late: { select: { id: true } },
153
+ absent: { select: { id: true } },
154
+ },
140
155
  });
141
156
  }
142
157
 
@@ -85,6 +85,60 @@ export function findUserByUsername(username: string) {
85
85
  });
86
86
  }
87
87
 
88
+ /**
89
+ * Search users who share at least one active school membership with the
90
+ * requester and match the query by username or profile display name.
91
+ *
92
+ * The requester is always excluded so the result set can be used directly as
93
+ * conversation member suggestions.
94
+ */
95
+ export async function searchInstitutionConversationMembers(
96
+ userId: string,
97
+ query: string,
98
+ limit: number
99
+ ) {
100
+ const memberships = await prisma.schoolMembership.findMany({
101
+ where: { userId, status: "ACTIVE" },
102
+ select: { schoolId: true },
103
+ });
104
+ const schoolIds = memberships.map((membership) => membership.schoolId);
105
+
106
+ if (schoolIds.length === 0) return [];
107
+
108
+ return prisma.user.findMany({
109
+ where: {
110
+ id: { not: userId },
111
+ schoolMemberships: {
112
+ some: {
113
+ schoolId: { in: schoolIds },
114
+ status: "ACTIVE",
115
+ },
116
+ },
117
+ OR: [
118
+ { username: { contains: query, mode: "insensitive" } },
119
+ {
120
+ profile: {
121
+ is: { displayName: { contains: query, mode: "insensitive" } },
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ select: {
127
+ id: true,
128
+ username: true,
129
+ profile: {
130
+ select: {
131
+ displayName: true,
132
+ profilePicture: true,
133
+ profilePictureThumbnail: true,
134
+ },
135
+ },
136
+ },
137
+ orderBy: { username: "asc" },
138
+ take: limit,
139
+ });
140
+ }
141
+
88
142
  /** @returns User's DM conversations. */
89
143
  export function findDmConversationsByUserId(userId: string) {
90
144
  return prisma.conversation.findMany({