@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.
- package/.github/workflows/npm.yml +28 -0
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +2 -4
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +4 -5
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/models/assignment.d.ts +32 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/assignment.js +111 -2
- package/dist/models/assignment.js.map +1 -1
- package/dist/models/auth.d.ts +1 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +3 -2
- package/dist/models/auth.js.map +1 -1
- package/dist/models/comment.d.ts +0 -5
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +2 -1
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +2 -1
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +6 -3
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/school.d.ts +0 -22
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +2 -14
- package/dist/models/school.js.map +1 -1
- package/dist/models/worksheet.d.ts +174 -95
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +38 -5
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.js +6 -6
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +5 -3
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +60 -24
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +3 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +65 -24
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +232 -48
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +0 -4
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +35 -8
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +10 -3
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/auth.d.ts +1 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/class.d.ts +19 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +0 -3
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/conversation.d.ts +2 -1
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +2 -1
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +7 -2
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/worksheet.d.ts +51 -7
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +10 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/assignment.d.ts +30 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +37 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/class.d.ts +19 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +37 -19
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +0 -3
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/conversation.d.ts +2 -1
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/labChat.d.ts +2 -1
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +6 -6
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +14 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +71 -6
- package/dist/services/school.js.map +1 -1
- package/dist/services/worksheet.d.ts +46 -7
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +52 -20
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/generateUsername.d.ts +8 -0
- package/dist/utils/generateUsername.d.ts.map +1 -0
- package/dist/utils/generateUsername.js +103 -0
- package/dist/utils/generateUsername.js.map +1 -0
- package/dist/workers/connection.d.ts +3 -0
- package/dist/workers/connection.d.ts.map +1 -0
- package/dist/workers/connection.js +33 -0
- package/dist/workers/connection.js.map +1 -0
- package/dist/workers/index.d.ts +10 -0
- package/dist/workers/index.d.ts.map +1 -0
- package/dist/workers/index.js +53 -0
- package/dist/workers/index.js.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts +9 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.js +100 -0
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
- package/dist/workers/queues.d.ts +69 -0
- package/dist/workers/queues.d.ts.map +1 -0
- package/dist/workers/queues.js +119 -0
- package/dist/workers/queues.js.map +1 -0
- package/package.json +4 -1
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +1 -1
- package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
- package/prisma/schema.prisma +465 -387
- package/src/lib/config/cors.ts +0 -2
- package/src/lib/fileUpload.ts +2 -4
- package/src/models/assignment.ts +132 -0
- package/src/models/auth.ts +1 -0
- package/src/models/school.ts +0 -14
- package/src/models/worksheet.ts +56 -2
- package/src/pipelines/aiLabChat.ts +4 -4
- package/src/pipelines/gradeWorksheet.ts +63 -25
- package/src/pipelines/pdfToWorksheet.ts +68 -22
- package/src/routers/assignment.ts +19 -0
- package/src/routers/school.ts +5 -0
- package/src/routers/worksheet.ts +18 -0
- package/src/services/assignment.ts +47 -1
- package/src/services/class.ts +41 -17
- package/src/services/labChat.ts +4 -4
- package/src/services/message.ts +13 -0
- package/src/services/school.ts +89 -6
- package/src/services/worksheet.ts +62 -18
- package/src/utils/generateUsername.ts +108 -0
- package/src/workers/connection.ts +37 -0
- package/src/workers/index.ts +61 -0
- package/src/workers/processors/pdfToWorksheet.ts +119 -0
- package/src/workers/queues.ts +159 -0
- package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
- package/tests/{helpers.ts → integration/helpers.ts} +2 -2
- package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
- package/tests/{routers → integration/routers}/attendance.test.ts +1 -1
- package/tests/{routers → integration/routers}/auth.test.ts +3 -3
- package/tests/{routers → integration/routers}/class.test.ts +3 -17
- package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
- package/tests/{routers → integration/routers}/event.test.ts +1 -1
- package/tests/{routers → integration/routers}/message.test.ts +1 -1
- package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
- package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
- package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
- package/tests/integration/routers/worksheet.test.ts +448 -0
- package/tests/{setup.ts → integration/setup.ts} +4 -4
- package/tests/{lib → unit/lib}/cors.test.ts +1 -1
- package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
- package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
- package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
- package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
- package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
- package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
- package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
- package/tests/{utils → unit/utils}/inference.test.ts +6 -6
- package/tests/{utils → unit/utils}/logger.test.ts +1 -1
- package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
- package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
- package/vitest.config.ts +11 -3
- package/vitest.unit.config.ts +10 -10
- package/dist/lib/notificationHandler.d.ts +0 -25
- package/dist/lib/notificationHandler.d.ts.map +0 -1
- package/dist/lib/notificationHandler.js +0 -32
- package/dist/lib/notificationHandler.js.map +0 -1
- package/dist/server/pipelines/aiLabChat.d.ts +0 -21
- package/dist/server/pipelines/aiLabChat.d.ts.map +0 -1
- package/dist/server/pipelines/aiLabChat.js +0 -456
- package/dist/server/pipelines/aiLabChat.js.map +0 -1
- package/dist/server/pipelines/aiNewtonChat.d.ts +0 -30
- package/dist/server/pipelines/aiNewtonChat.d.ts.map +0 -1
- package/dist/server/pipelines/aiNewtonChat.js +0 -285
- package/dist/server/pipelines/aiNewtonChat.js.map +0 -1
- package/dist/server/pipelines/gradeWorksheet.d.ts +0 -30
- package/dist/server/pipelines/gradeWorksheet.d.ts.map +0 -1
- package/dist/server/pipelines/gradeWorksheet.js +0 -248
- package/dist/server/pipelines/gradeWorksheet.js.map +0 -1
- package/tests/routers/worksheet.test.ts +0 -194
- package/tests/startup.test.ts +0 -5
- package/tests/worksheet.test.ts +0 -181
- /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
- /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
- /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
- /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
- /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
- /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
- /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
- /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
- /package/tests/{routers → integration/routers}/search.test.ts +0 -0
- /package/tests/{routers → integration/routers}/section.test.ts +0 -0
- /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Worker, type Job } from 'bullmq';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import { bullConnection } from '../connection.js';
|
|
4
|
+
import { prisma } from '../../lib/prisma.js';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';
|
|
7
|
+
|
|
8
|
+
interface PdfToWorksheetJobData {
|
|
9
|
+
runJobId: string;
|
|
10
|
+
gcsUrl: string;
|
|
11
|
+
classId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function process(job: Job<PdfToWorksheetJobData>) {
|
|
15
|
+
const { runJobId, gcsUrl, classId } = job.data;
|
|
16
|
+
|
|
17
|
+
// If a previous attempt already succeeded for this RunJob (rare race after a
|
|
18
|
+
// crash that lost the BullMQ ack), return the prior result instead of
|
|
19
|
+
// re-running the AI call and creating a duplicate worksheet.
|
|
20
|
+
const existing = await prisma.runJob.findUnique({
|
|
21
|
+
where: { id: runJobId },
|
|
22
|
+
select: { status: true, result: true },
|
|
23
|
+
});
|
|
24
|
+
if (existing?.status === 'SUCCEEDED') {
|
|
25
|
+
logger.info('pdfToWorksheet RunJob already succeeded; skipping re-run', {
|
|
26
|
+
runJobId,
|
|
27
|
+
bullJobId: job.id,
|
|
28
|
+
});
|
|
29
|
+
return existing.result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await prisma.runJob.update({
|
|
33
|
+
where: { id: runJobId },
|
|
34
|
+
data: {
|
|
35
|
+
status: 'RUNNING',
|
|
36
|
+
startedAt: existing?.status === 'RUNNING' ? undefined : new Date(),
|
|
37
|
+
attempts: { increment: 1 },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const worksheet = await pdfToWorksheet(gcsUrl, classId);
|
|
42
|
+
|
|
43
|
+
const result = worksheet ? { worksheetId: worksheet.id, name: worksheet.name } : null;
|
|
44
|
+
|
|
45
|
+
await prisma.runJob.update({
|
|
46
|
+
where: { id: runJobId },
|
|
47
|
+
data: {
|
|
48
|
+
status: 'SUCCEEDED',
|
|
49
|
+
finishedAt: new Date(),
|
|
50
|
+
error: null,
|
|
51
|
+
result: result === null ? Prisma.JsonNull : (result as Prisma.InputJsonValue),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const pdfToWorksheetWorker = new Worker<PdfToWorksheetJobData>(
|
|
59
|
+
'pdfToWorksheet',
|
|
60
|
+
process,
|
|
61
|
+
{
|
|
62
|
+
connection: bullConnection,
|
|
63
|
+
// Conservative defaults — tune once we have real-world numbers.
|
|
64
|
+
concurrency: 2,
|
|
65
|
+
limiter: {
|
|
66
|
+
max: 5,
|
|
67
|
+
duration: 60_000, // 5 jobs / minute, e.g. to stay under OpenAI rate limits
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
pdfToWorksheetWorker.on('error', (err) => {
|
|
73
|
+
logger.error('pdfToWorksheet worker error', {
|
|
74
|
+
error: err.message,
|
|
75
|
+
stack: err.stack,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pdfToWorksheetWorker.on('failed', async (job, err) => {
|
|
80
|
+
if (!job) return;
|
|
81
|
+
|
|
82
|
+
const runJobId = job.data?.runJobId;
|
|
83
|
+
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? 1);
|
|
84
|
+
|
|
85
|
+
logger.error('pdfToWorksheet job failed', {
|
|
86
|
+
bullJobId: job.id,
|
|
87
|
+
runJobId,
|
|
88
|
+
attempt: job.attemptsMade,
|
|
89
|
+
finalAttempt: isFinalAttempt,
|
|
90
|
+
error: err.message,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!runJobId) return;
|
|
94
|
+
|
|
95
|
+
// Always record the latest error message on the RunJob row.
|
|
96
|
+
// Only flip to FAILED on the final attempt — earlier failures are still
|
|
97
|
+
// "RUNNING" because BullMQ is going to retry.
|
|
98
|
+
await prisma.runJob.update({
|
|
99
|
+
where: { id: runJobId },
|
|
100
|
+
data: {
|
|
101
|
+
error: err.message,
|
|
102
|
+
...(isFinalAttempt
|
|
103
|
+
? { status: 'FAILED', finishedAt: new Date() }
|
|
104
|
+
: {}),
|
|
105
|
+
},
|
|
106
|
+
}).catch((updateError) => {
|
|
107
|
+
logger.error('Failed to record pdfToWorksheet failure on RunJob', {
|
|
108
|
+
runJobId,
|
|
109
|
+
error: updateError instanceof Error ? updateError.message : String(updateError),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pdfToWorksheetWorker.on('completed', (job) => {
|
|
115
|
+
logger.info('pdfToWorksheet job completed', {
|
|
116
|
+
bullJobId: job.id,
|
|
117
|
+
runJobId: job.data?.runJobId,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
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 { Queue, type JobsOptions } from 'bullmq';
|
|
10
|
+
import { Prisma, type RunJobType } from '@prisma/client';
|
|
11
|
+
import { bullConnection } from './connection.js';
|
|
12
|
+
import { prisma } from '../lib/prisma.js';
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_JOB_OPTIONS: JobsOptions = {
|
|
16
|
+
attempts: 3,
|
|
17
|
+
backoff: { type: 'exponential', delay: 5_000 },
|
|
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
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map RunJobType → (queue name, input payload shape).
|
|
24
|
+
*
|
|
25
|
+
* Add a new entry here whenever you introduce a new background job type.
|
|
26
|
+
* The queue name string must match what the corresponding processor passes
|
|
27
|
+
* to `new Worker(<name>, ...)`.
|
|
28
|
+
*/
|
|
29
|
+
type JobInputs = {
|
|
30
|
+
PDF_TO_WORKSHEET: { gcsUrl: string; classId: string };
|
|
31
|
+
GRADE_WORKSHEET: { worksheetResponseId: string };
|
|
32
|
+
AI_LAB_CHAT: { conversationId: string; messageId: string };
|
|
33
|
+
AI_NEWTON_CHAT: { conversationId: string; messageId: string };
|
|
34
|
+
STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const QUEUE_NAMES: Record<RunJobType, string> = {
|
|
38
|
+
PDF_TO_WORKSHEET: 'pdfToWorksheet',
|
|
39
|
+
GRADE_WORKSHEET: 'gradeWorksheet',
|
|
40
|
+
AI_LAB_CHAT: 'aiLabChat',
|
|
41
|
+
AI_NEWTON_CHAT: 'aiNewtonChat',
|
|
42
|
+
STUDENT_PROGRESS_CHAT: 'studentProgressChat',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One Queue instance per type. Created lazily so that importing this module
|
|
47
|
+
* does not eagerly open queues you may never use in a given runtime.
|
|
48
|
+
*/
|
|
49
|
+
const queueCache = new Map<RunJobType, Queue>();
|
|
50
|
+
|
|
51
|
+
function getQueue(type: RunJobType): Queue {
|
|
52
|
+
let queue = queueCache.get(type);
|
|
53
|
+
if (!queue) {
|
|
54
|
+
queue = new Queue(QUEUE_NAMES[type], {
|
|
55
|
+
connection: bullConnection,
|
|
56
|
+
defaultJobOptions: DEFAULT_JOB_OPTIONS,
|
|
57
|
+
});
|
|
58
|
+
queueCache.set(type, queue);
|
|
59
|
+
}
|
|
60
|
+
return queue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface EnqueueOptions {
|
|
64
|
+
classId?: string | null;
|
|
65
|
+
userId?: string | null;
|
|
66
|
+
/** Override BullMQ job options (attempts, delay, priority, etc.). */
|
|
67
|
+
jobOptions?: JobsOptions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface EnqueueResult {
|
|
71
|
+
runJobId: string;
|
|
72
|
+
bullJobId: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a `RunJob` audit row, enqueue the BullMQ job, and link the two.
|
|
77
|
+
*
|
|
78
|
+
* Returns immediately — the actual work happens on the worker process. The
|
|
79
|
+
* caller can return `runJobId` to the client and have them poll `getRunJob`
|
|
80
|
+
* (or subscribe over Pusher) to track progress.
|
|
81
|
+
*
|
|
82
|
+
* IMPORTANT: do not call this inside a Prisma `$transaction`. If the outer
|
|
83
|
+
* transaction rolls back after the BullMQ job is added, the worker will pick
|
|
84
|
+
* up an orphaned job pointing at a non-existent RunJob row. Enqueue *after*
|
|
85
|
+
* the transaction commits.
|
|
86
|
+
*/
|
|
87
|
+
export async function enqueueRunJob<T extends RunJobType>(
|
|
88
|
+
type: T,
|
|
89
|
+
input: JobInputs[T],
|
|
90
|
+
options: EnqueueOptions = {},
|
|
91
|
+
): Promise<EnqueueResult> {
|
|
92
|
+
const runJob = await prisma.runJob.create({
|
|
93
|
+
data: {
|
|
94
|
+
type,
|
|
95
|
+
status: 'PENDING',
|
|
96
|
+
classId: options.classId ?? null,
|
|
97
|
+
userId: options.userId ?? null,
|
|
98
|
+
input: input as unknown as Prisma.InputJsonValue,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const queue = getQueue(type);
|
|
104
|
+
const job = await queue.add(
|
|
105
|
+
QUEUE_NAMES[type],
|
|
106
|
+
{ runJobId: runJob.id, ...input },
|
|
107
|
+
options.jobOptions,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await prisma.runJob.update({
|
|
112
|
+
where: { id: runJob.id },
|
|
113
|
+
data: { bullJobId: job.id ?? null },
|
|
114
|
+
});
|
|
115
|
+
} catch (linkError) {
|
|
116
|
+
logger.error('RunJob enqueued but bullJobId linkage failed', {
|
|
117
|
+
runJobId: runJob.id,
|
|
118
|
+
bullJobId: job.id ?? null,
|
|
119
|
+
type,
|
|
120
|
+
error: linkError instanceof Error ? linkError.message : String(linkError),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { runJobId: runJob.id, bullJobId: job.id ?? null };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error('Failed to enqueue RunJob; marking FAILED', {
|
|
127
|
+
runJobId: runJob.id,
|
|
128
|
+
type,
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await prisma.runJob.update({
|
|
134
|
+
where: { id: runJob.id },
|
|
135
|
+
data: {
|
|
136
|
+
status: 'FAILED',
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
finishedAt: new Date(),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
} catch (updateError) {
|
|
142
|
+
logger.error('Failed to mark RunJob as FAILED after enqueue error', {
|
|
143
|
+
runJobId: runJob.id,
|
|
144
|
+
error: updateError instanceof Error ? updateError.message : String(updateError),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Close every cached queue. Call from the HTTP server's shutdown handler so
|
|
154
|
+
* the producer side disconnects from Redis cleanly.
|
|
155
|
+
*/
|
|
156
|
+
export async function closeAllQueues(): Promise<void> {
|
|
157
|
+
await Promise.all(Array.from(queueCache.values()).map((q) => q.close()));
|
|
158
|
+
queueCache.clear();
|
|
159
|
+
}
|
|
@@ -4,10 +4,10 @@ import type { TestProject } from 'vitest/node';
|
|
|
4
4
|
|
|
5
5
|
config({ path: resolve(process.cwd(), '.env.test'), override: true });
|
|
6
6
|
|
|
7
|
-
import { prisma } from '
|
|
8
|
-
import { appRouter } from '
|
|
9
|
-
import { createTRPCContext } from '
|
|
10
|
-
import { clearDatabase } from '
|
|
7
|
+
import { prisma } from '../../src/lib/prisma';
|
|
8
|
+
import { appRouter } from '../../src/routers/_app';
|
|
9
|
+
import { createTRPCContext } from '../../src/trpc';
|
|
10
|
+
import { clearDatabase } from '../../src/seedDatabase';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
12
|
import { SessionType } from '@prisma/client';
|
|
13
13
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createTRPCContext } from '
|
|
2
|
-
import { appRouter } from '
|
|
1
|
+
import { createTRPCContext } from '../../src/trpc';
|
|
2
|
+
import { appRouter } from '../../src/routers/_app';
|
|
3
3
|
|
|
4
4
|
export async function createUnauthCaller() {
|
|
5
5
|
const ctx = await createTRPCContext({
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `pdfToWorksheet` integration-style tests: real DB, mocked
|
|
3
|
-
*
|
|
2
|
+
* `pdfToWorksheet` integration-style tests: real DB, mocked OpenAI.
|
|
3
|
+
*
|
|
4
|
+
* We intercept the `responses.parse` method on the shared openAIClient
|
|
5
|
+
* instance instead of `vi.mock`ing the inference module: setupFiles loads
|
|
6
|
+
* the inference module (via appRouter) before any test file's vi.mock can
|
|
7
|
+
* register, so module mocks don't intercept those cached imports.
|
|
4
8
|
*/
|
|
5
9
|
import { describe, test, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
6
10
|
import { user1Caller } from "../setup";
|
|
7
|
-
import { prisma } from "
|
|
8
|
-
import { logger } from "
|
|
11
|
+
import { prisma } from "../../../src/lib/prisma";
|
|
12
|
+
import { logger } from "../../../src/utils/logger";
|
|
13
|
+
import { openAIClient } from "../../../src/utils/inference.js";
|
|
14
|
+
import { pdfToWorksheet } from "../../../src/pipelines/pdfToWorksheet.js";
|
|
9
15
|
|
|
10
|
-
vi.
|
|
11
|
-
inference: vi.fn(),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
import { inference } from "../../src/utils/inference.js";
|
|
15
|
-
import { pdfToWorksheet } from "../../src/pipelines/pdfToWorksheet.js";
|
|
16
|
+
const mockedResponsesParse = vi.spyOn(openAIClient.responses, "parse");
|
|
16
17
|
|
|
17
18
|
/** Same PDF path represented as a signed Google Cloud Storage URL. */
|
|
18
19
|
export const CONV_TEST_PDF_GCS_URL =
|
|
@@ -68,7 +69,7 @@ describe("pdfToWorksheet", () => {
|
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
beforeEach(() => {
|
|
71
|
-
|
|
72
|
+
mockedResponsesParse.mockReset();
|
|
72
73
|
});
|
|
73
74
|
|
|
74
75
|
afterEach(async () => {
|
|
@@ -79,7 +80,7 @@ describe("pdfToWorksheet", () => {
|
|
|
79
80
|
|
|
80
81
|
test("passes the PDF URL directly to inference and persists a worksheet with 8 questions", async () => {
|
|
81
82
|
const draft = draftWithEightQuestions();
|
|
82
|
-
|
|
83
|
+
mockedResponsesParse.mockResolvedValue({ output_parsed: draft } as any);
|
|
83
84
|
|
|
84
85
|
const worksheet = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
85
86
|
expect(worksheet).not.toBeNull();
|
|
@@ -87,6 +88,9 @@ describe("pdfToWorksheet", () => {
|
|
|
87
88
|
|
|
88
89
|
expect(worksheet!.name).toBe(draft.title);
|
|
89
90
|
expect(worksheet!.classId).toBe(classId);
|
|
91
|
+
expect(worksheet!.sourceUrl).toBe(
|
|
92
|
+
"https://storage.googleapis.com/studious-test-bucket/worksheets/conv-test.pdf"
|
|
93
|
+
);
|
|
90
94
|
|
|
91
95
|
const questions = await prisma.worksheetQuestion.findMany({
|
|
92
96
|
where: { worksheetId: worksheet!.id },
|
|
@@ -99,27 +103,17 @@ describe("pdfToWorksheet", () => {
|
|
|
99
103
|
expect(questions.map((q) => q.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
100
104
|
expect(questions.map((q) => q.points)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
101
105
|
|
|
102
|
-
expect(
|
|
103
|
-
const
|
|
104
|
-
expect(Array.isArray(input)).toBe(true);
|
|
106
|
+
expect(mockedResponsesParse).toHaveBeenCalledTimes(1);
|
|
107
|
+
const callArgs = mockedResponsesParse.mock.calls[0][0] as { input: unknown };
|
|
105
108
|
type InputPart = { type?: string; file_url?: string; text?: string };
|
|
106
|
-
const
|
|
109
|
+
const input = callArgs.input as { role: string; content: InputPart[] }[];
|
|
110
|
+
expect(Array.isArray(input)).toBe(true);
|
|
111
|
+
const content = input[0].content;
|
|
107
112
|
const filePart = content.find((p) => p.type === "input_file");
|
|
108
113
|
expect(filePart).toEqual({
|
|
109
114
|
type: "input_file",
|
|
110
115
|
file_url: CONV_TEST_PDF_GCS_URL,
|
|
111
116
|
});
|
|
112
|
-
expect(vi.mocked(inference)).toHaveBeenCalledWith(
|
|
113
|
-
expect.any(Array),
|
|
114
|
-
expect.anything(),
|
|
115
|
-
expect.objectContaining({
|
|
116
|
-
reason: "pdf_to_worksheet_draft",
|
|
117
|
-
metadata: expect.objectContaining({
|
|
118
|
-
classId,
|
|
119
|
-
filename: "conv-test.pdf",
|
|
120
|
-
}),
|
|
121
|
-
})
|
|
122
|
-
);
|
|
123
117
|
});
|
|
124
118
|
|
|
125
119
|
test("rejects non-Google Cloud URLs and only logs a sanitized path", async () => {
|
|
@@ -133,23 +127,25 @@ describe("pdfToWorksheet", () => {
|
|
|
133
127
|
"pdfToWorksheet: rejected unsupported PDF URL",
|
|
134
128
|
{ sanitizedUrl: "https://drive.google.com/file/d/1BfotteM60Ax0krHzE6rFN1Df8S8kT3kb/view" }
|
|
135
129
|
);
|
|
136
|
-
expect(
|
|
130
|
+
expect(mockedResponsesParse).not.toHaveBeenCalled();
|
|
137
131
|
warnSpy.mockRestore();
|
|
138
132
|
});
|
|
139
133
|
|
|
140
134
|
test("rounds persisted integer fields from model output", async () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
135
|
+
mockedResponsesParse.mockResolvedValue({
|
|
136
|
+
output_parsed: {
|
|
137
|
+
title: "Rounded worksheet",
|
|
138
|
+
questions: [{
|
|
139
|
+
type: "SHORT_ANSWER",
|
|
140
|
+
question: "Rounded question",
|
|
141
|
+
answer: "Rounded answer",
|
|
142
|
+
options: [],
|
|
143
|
+
markScheme: [{ id: "step-1", points: 0.6, description: "Half step" }],
|
|
144
|
+
points: 1.7,
|
|
145
|
+
order: 1.2,
|
|
146
|
+
}],
|
|
147
|
+
},
|
|
148
|
+
} as any);
|
|
153
149
|
|
|
154
150
|
const worksheet = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
155
151
|
expect(worksheet).not.toBeNull();
|
|
@@ -165,12 +161,28 @@ describe("pdfToWorksheet", () => {
|
|
|
165
161
|
});
|
|
166
162
|
|
|
167
163
|
test("returns null when inference yields no parsed output", async () => {
|
|
168
|
-
|
|
169
|
-
null as unknown as Awaited<ReturnType<typeof inference>>
|
|
170
|
-
);
|
|
164
|
+
mockedResponsesParse.mockResolvedValue({ output_parsed: null } as any);
|
|
171
165
|
|
|
172
166
|
const out = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
173
167
|
expect(out).toBeNull();
|
|
174
168
|
});
|
|
175
169
|
|
|
170
|
+
test("reuses an existing worksheet for the same PDF source", async () => {
|
|
171
|
+
mockedResponsesParse.mockResolvedValue({
|
|
172
|
+
output_parsed: draftWithEightQuestions("Idempotent worksheet"),
|
|
173
|
+
} as any);
|
|
174
|
+
|
|
175
|
+
const first = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
176
|
+
expect(first).not.toBeNull();
|
|
177
|
+
createdWorksheetIds.push(first!.id);
|
|
178
|
+
|
|
179
|
+
const second = await pdfToWorksheet(
|
|
180
|
+
"https://storage.googleapis.com/studious-test-bucket/worksheets/conv-test.pdf?X-Goog-Signature=retry-signature",
|
|
181
|
+
classId
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(second?.id).toBe(first!.id);
|
|
185
|
+
expect(mockedResponsesParse).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
176
188
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach } from 'vitest';
|
|
2
2
|
import { user1Caller, user2Caller } from '../setup';
|
|
3
3
|
import { expectUnauth } from '../helpers';
|
|
4
|
-
import { prisma } from '
|
|
4
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
5
5
|
|
|
6
6
|
describe('Attendance Router', () => {
|
|
7
7
|
let testClass: any;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { SessionType } from '@prisma/client';
|
|
5
5
|
import { test, expect, describe } from 'vitest';
|
|
6
|
-
import { appRouter } from '
|
|
7
|
-
import { createTRPCContext } from '
|
|
8
|
-
import { prisma } from '
|
|
6
|
+
import { appRouter } from '../../../src/routers/_app';
|
|
7
|
+
import { createTRPCContext } from '../../../src/trpc';
|
|
8
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
9
9
|
import {
|
|
10
10
|
caller,
|
|
11
11
|
login1,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { expectForbidden, expectRejects, expectUnauth } from '../helpers';
|
|
3
|
-
import { prisma } from '
|
|
3
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
4
4
|
import { caller, user1Caller, user2Caller, user3Caller } from '../setup';
|
|
5
5
|
|
|
6
|
-
vi.mock('
|
|
7
|
-
const actual = await importOriginal<typeof import('
|
|
6
|
+
vi.mock('../../../src/lib/googleCloudStorage.js', async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import('../../../src/lib/googleCloudStorage.js')>();
|
|
8
8
|
return {
|
|
9
9
|
...actual,
|
|
10
10
|
copyFile: vi.fn().mockResolvedValue(undefined),
|
|
@@ -340,19 +340,5 @@ describe('Class Router', () => {
|
|
|
340
340
|
expect(rootFolder.name).toBe('Class Files');
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
-
|
|
344
|
-
test('non-teacher cannot import class', async () => {
|
|
345
|
-
await user1Caller.folder.getRootFolder({ classId: testClass.id });
|
|
346
|
-
const exported = await user1Caller.class.exportClass({ classId: testClass.id });
|
|
347
|
-
const newClassId = `import-forbidden-${Date.now()}`;
|
|
348
|
-
|
|
349
|
-
await expect(
|
|
350
|
-
user3Caller.class.importClass({
|
|
351
|
-
classId: newClassId,
|
|
352
|
-
year: new Date().getFullYear(),
|
|
353
|
-
classData: exported,
|
|
354
|
-
})
|
|
355
|
-
).rejects.toThrow();
|
|
356
|
-
});
|
|
357
343
|
});
|
|
358
344
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe, beforeAll } from 'vitest';
|
|
2
|
-
import { prisma } from '
|
|
2
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
3
3
|
import { user1Caller, user2Caller, user3Caller } from '../setup';
|
|
4
4
|
import { expectUnauth } from '../helpers';
|
|
5
5
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach } from 'vitest';
|
|
2
2
|
import { user1Caller, user2Caller } from '../setup';
|
|
3
3
|
import { expectRejects, expectUnauth } from '../helpers';
|
|
4
|
-
import { prisma } from '
|
|
4
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
5
5
|
import { startOfWeek } from 'date-fns';
|
|
6
6
|
|
|
7
7
|
describe('Event Router', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe, beforeAll } from 'vitest';
|
|
2
2
|
import { user1Caller, user2Caller, user3Caller } from '../setup';
|
|
3
|
-
import { prisma } from '
|
|
3
|
+
import { prisma } from '../../../src/lib/prisma';
|
|
4
4
|
|
|
5
5
|
const expectTRPCError = async (promise: Promise<unknown>, code: string) => {
|
|
6
6
|
await expect(promise).rejects.toMatchObject({ code });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach, vi } from "vitest";
|
|
2
|
-
import { openAIClient } from "
|
|
2
|
+
import { openAIClient } from "../../../src/utils/inference.js";
|
|
3
3
|
import { user1Caller, user2Caller, user3Caller } from "../setup";
|
|
4
4
|
|
|
5
5
|
const mockedCompletionCreate = vi.spyOn(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach } from 'vitest';
|
|
2
|
-
import { user1Caller, user2Caller } from '
|
|
3
|
-
import { expectUnauth } from '
|
|
2
|
+
import { user1Caller, user2Caller } from '../setup';
|
|
3
|
+
import { expectUnauth } from '../helpers';
|
|
4
4
|
|
|
5
5
|
describe('User Router', () => {
|
|
6
6
|
describe('getProfile', () => {
|