Deploy legacy receipt and recovery update
Browse files- src/cron/cleanup-reports.ts +47 -18
- src/db/accounts.ts +4 -2
- src/db/jobs.ts +39 -2
- src/db/storage.ts +38 -3
- src/db/tickets.ts +41 -21
- src/engine/legacy.ts +68 -19
- src/engine/turnitin.ts +1 -0
- src/server/app.ts +1 -1
- src/server/routes/reports.ts +74 -0
- src/server/routes/submit.ts +103 -13
- src/worker/manager.ts +135 -37
src/cron/cleanup-reports.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { config } from '../config';
|
| 2 |
import { logger } from '../utils/logger';
|
| 3 |
-
import { getExpiredReportJobs,
|
| 4 |
import { deleteReportPdf } from '../db/storage';
|
| 5 |
import { insertJobEvent } from '../db/events';
|
| 6 |
|
|
@@ -8,8 +8,8 @@ import { insertJobEvent } from '../db/events';
|
|
| 8 |
* Periodic cleanup of expired PDF reports.
|
| 9 |
* Runs every CLEANUP_INTERVAL minutes.
|
| 10 |
*
|
| 11 |
-
* Deletes
|
| 12 |
-
*
|
| 13 |
*/
|
| 14 |
export async function runCleanupReportsCron(): Promise<void> {
|
| 15 |
const cronLog = logger.child({ cron: 'cleanup-reports' });
|
|
@@ -29,18 +29,18 @@ export async function runCleanupReportsCron(): Promise<void> {
|
|
| 29 |
let failed = 0;
|
| 30 |
|
| 31 |
for (const job of expiredJobs) {
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
await deleteReportPdf(job.output_pdf_path);
|
| 35 |
deleted++;
|
|
|
|
| 36 |
|
| 37 |
-
// Clear the PDF path so we don't try to delete again,
|
| 38 |
-
// but keep the path in the audit trail via job_events
|
| 39 |
-
await updateJobStatus(job.id, job.status as string, {
|
| 40 |
-
output_pdf_path: null,
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
// Log the cleanup event
|
| 44 |
await insertJobEvent({
|
| 45 |
job_id: job.id,
|
| 46 |
identity_id: null,
|
|
@@ -51,13 +51,42 @@ export async function runCleanupReportsCron(): Promise<void> {
|
|
| 51 |
});
|
| 52 |
|
| 53 |
cronLog.info(`Deleted expired report for job ${job.id}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
-
} catch (err) {
|
| 56 |
-
failed++;
|
| 57 |
-
cronLog.error(`Failed to delete report for job ${job.id}`, {
|
| 58 |
-
error: err instanceof Error ? err.message : String(err),
|
| 59 |
-
path: job.output_pdf_path,
|
| 60 |
-
});
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
|
|
|
| 1 |
import { config } from '../config';
|
| 2 |
import { logger } from '../utils/logger';
|
| 3 |
+
import { getExpiredReportJobs, updateJobFields } from '../db/jobs';
|
| 4 |
import { deleteReportPdf } from '../db/storage';
|
| 5 |
import { insertJobEvent } from '../db/events';
|
| 6 |
|
|
|
|
| 8 |
* Periodic cleanup of expired PDF reports.
|
| 9 |
* Runs every CLEANUP_INTERVAL minutes.
|
| 10 |
*
|
| 11 |
+
* Deletes report and Digital Receipt PDFs after their independent expiry time.
|
| 12 |
+
* Deleted paths remain available in job events for the audit trail.
|
| 13 |
*/
|
| 14 |
export async function runCleanupReportsCron(): Promise<void> {
|
| 15 |
const cronLog = logger.child({ cron: 'cleanup-reports' });
|
|
|
|
| 29 |
let failed = 0;
|
| 30 |
|
| 31 |
for (const job of expiredJobs) {
|
| 32 |
+
const now = Date.now();
|
| 33 |
+
|
| 34 |
+
if (
|
| 35 |
+
job.output_pdf_path &&
|
| 36 |
+
job.output_pdf_expires_at &&
|
| 37 |
+
new Date(job.output_pdf_expires_at).getTime() <= now
|
| 38 |
+
) {
|
| 39 |
+
try {
|
| 40 |
await deleteReportPdf(job.output_pdf_path);
|
| 41 |
deleted++;
|
| 42 |
+
await updateJobFields(job.id, { output_pdf_path: null });
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
await insertJobEvent({
|
| 45 |
job_id: job.id,
|
| 46 |
identity_id: null,
|
|
|
|
| 51 |
});
|
| 52 |
|
| 53 |
cronLog.info(`Deleted expired report for job ${job.id}`);
|
| 54 |
+
} catch (err) {
|
| 55 |
+
failed++;
|
| 56 |
+
cronLog.error(`Failed to delete report for job ${job.id}`, {
|
| 57 |
+
error: err instanceof Error ? err.message : String(err),
|
| 58 |
+
path: job.output_pdf_path,
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (
|
| 64 |
+
job.receipt_pdf_path &&
|
| 65 |
+
job.receipt_pdf_expires_at &&
|
| 66 |
+
new Date(job.receipt_pdf_expires_at).getTime() <= now
|
| 67 |
+
) {
|
| 68 |
+
try {
|
| 69 |
+
await deleteReportPdf(job.receipt_pdf_path);
|
| 70 |
+
deleted++;
|
| 71 |
+
await updateJobFields(job.id, { receipt_pdf_path: null });
|
| 72 |
+
|
| 73 |
+
await insertJobEvent({
|
| 74 |
+
job_id: job.id,
|
| 75 |
+
identity_id: null,
|
| 76 |
+
level: 'info',
|
| 77 |
+
step: 'receipt_expired_deleted',
|
| 78 |
+
message: `Digital Receipt deleted after expiry: ${job.receipt_pdf_path}`,
|
| 79 |
+
metadata: { expired_path: job.receipt_pdf_path },
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
cronLog.info(`Deleted expired Digital Receipt for job ${job.id}`);
|
| 83 |
+
} catch (err) {
|
| 84 |
+
failed++;
|
| 85 |
+
cronLog.error(`Failed to delete Digital Receipt for job ${job.id}`, {
|
| 86 |
+
error: err instanceof Error ? err.message : String(err),
|
| 87 |
+
path: job.receipt_pdf_path,
|
| 88 |
+
});
|
| 89 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
}
|
| 92 |
|
src/db/accounts.ts
CHANGED
|
@@ -182,7 +182,9 @@ export async function claimAvailableAccount(
|
|
| 182 |
/**
|
| 183 |
* Claim the exact account that already owns a submitted job.
|
| 184 |
* This is used only for post-submit retries where the worker must reopen the
|
| 185 |
-
* same Turnitin report viewer instead of submitting the file again.
|
|
|
|
|
|
|
| 186 |
*/
|
| 187 |
export async function claimSpecificAccountForResume(
|
| 188 |
identityId: string,
|
|
@@ -200,7 +202,7 @@ export async function claimSpecificAccountForResume(
|
|
| 200 |
updated_at: now,
|
| 201 |
})
|
| 202 |
.eq('id', identityId)
|
| 203 |
-
.not('turnitin_status', 'in', '(disabled,login_failed
|
| 204 |
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now},turnitin_lease_owner.eq.${workerId}`)
|
| 205 |
.select('*')
|
| 206 |
.maybeSingle();
|
|
|
|
| 182 |
/**
|
| 183 |
* Claim the exact account that already owns a submitted job.
|
| 184 |
* This is used only for post-submit retries where the worker must reopen the
|
| 185 |
+
* same Turnitin report viewer instead of submitting the file again. A
|
| 186 |
+
* quota_limited account is still valid here because report access does not
|
| 187 |
+
* consume another submission; explicitly disabled/login_failed rows remain blocked.
|
| 188 |
*/
|
| 189 |
export async function claimSpecificAccountForResume(
|
| 190 |
identityId: string,
|
|
|
|
| 202 |
updated_at: now,
|
| 203 |
})
|
| 204 |
.eq('id', identityId)
|
| 205 |
+
.not('turnitin_status', 'in', '(disabled,login_failed)')
|
| 206 |
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now},turnitin_lease_owner.eq.${workerId}`)
|
| 207 |
.select('*')
|
| 208 |
.maybeSingle();
|
src/db/jobs.ts
CHANGED
|
@@ -12,8 +12,11 @@ export interface TurnitinJob {
|
|
| 12 |
input_file_name: string;
|
| 13 |
input_file_size: number | null;
|
| 14 |
input_file_sha256: string | null;
|
|
|
|
| 15 |
output_pdf_path: string | null;
|
| 16 |
output_pdf_expires_at: string | null;
|
|
|
|
|
|
|
| 17 |
ticket_refunded_at: string | null;
|
| 18 |
ticket_refund_reason: string | null;
|
| 19 |
viewer_url: string | null;
|
|
@@ -78,6 +81,37 @@ export async function updateJobStatus(
|
|
| 78 |
}
|
| 79 |
}
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
/**
|
| 82 |
* Patch arbitrary job fields without changing status.
|
| 83 |
* Used for incremental progress checkpoints so retries can resume safely.
|
|
@@ -131,11 +165,14 @@ export async function getJobById(jobId: string): Promise<TurnitinJob | null> {
|
|
| 131 |
* Find jobs whose report PDF has expired and should be cleaned up.
|
| 132 |
*/
|
| 133 |
export async function getExpiredReportJobs(): Promise<TurnitinJob[]> {
|
|
|
|
| 134 |
const { data, error } = await supabase
|
| 135 |
.from('turnitin_jobs')
|
| 136 |
.select('*')
|
| 137 |
-
.
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
|
| 140 |
if (error) {
|
| 141 |
logger.error('Failed to get expired report jobs', { error: error.message });
|
|
|
|
| 12 |
input_file_name: string;
|
| 13 |
input_file_size: number | null;
|
| 14 |
input_file_sha256: string | null;
|
| 15 |
+
submission_request_id: string | null;
|
| 16 |
output_pdf_path: string | null;
|
| 17 |
output_pdf_expires_at: string | null;
|
| 18 |
+
receipt_pdf_path: string | null;
|
| 19 |
+
receipt_pdf_expires_at: string | null;
|
| 20 |
ticket_refunded_at: string | null;
|
| 21 |
ticket_refund_reason: string | null;
|
| 22 |
viewer_url: string | null;
|
|
|
|
| 81 |
}
|
| 82 |
}
|
| 83 |
|
| 84 |
+
/**
|
| 85 |
+
* Mark a job completed without reviving a job that an administrator or user
|
| 86 |
+
* already moved to a terminal failed/cancelled state while Playwright was
|
| 87 |
+
* finishing in the background.
|
| 88 |
+
*/
|
| 89 |
+
export async function completeJobIfActive(
|
| 90 |
+
jobId: string,
|
| 91 |
+
fields: Partial<TurnitinJob>,
|
| 92 |
+
): Promise<boolean> {
|
| 93 |
+
const update: Record<string, unknown> = {
|
| 94 |
+
...fields,
|
| 95 |
+
status: 'completed',
|
| 96 |
+
updated_at: new Date().toISOString(),
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const { data, error } = await supabase
|
| 100 |
+
.from('turnitin_jobs')
|
| 101 |
+
.update(update)
|
| 102 |
+
.eq('id', jobId)
|
| 103 |
+
.not('status', 'in', '(failed,cancelled)')
|
| 104 |
+
.select('id')
|
| 105 |
+
.maybeSingle();
|
| 106 |
+
|
| 107 |
+
if (error) {
|
| 108 |
+
logger.error('Failed to complete active job', { jobId, error: error.message });
|
| 109 |
+
throw error;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return Boolean(data);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
/**
|
| 116 |
* Patch arbitrary job fields without changing status.
|
| 117 |
* Used for incremental progress checkpoints so retries can resume safely.
|
|
|
|
| 165 |
* Find jobs whose report PDF has expired and should be cleaned up.
|
| 166 |
*/
|
| 167 |
export async function getExpiredReportJobs(): Promise<TurnitinJob[]> {
|
| 168 |
+
const now = new Date().toISOString();
|
| 169 |
const { data, error } = await supabase
|
| 170 |
.from('turnitin_jobs')
|
| 171 |
.select('*')
|
| 172 |
+
.or(
|
| 173 |
+
`and(output_pdf_path.not.is.null,output_pdf_expires_at.lte.${now}),` +
|
| 174 |
+
`and(receipt_pdf_path.not.is.null,receipt_pdf_expires_at.lte.${now})`,
|
| 175 |
+
);
|
| 176 |
|
| 177 |
if (error) {
|
| 178 |
logger.error('Failed to get expired report jobs', { error: error.message });
|
src/db/storage.ts
CHANGED
|
@@ -10,18 +10,19 @@ import { logger } from '../utils/logger';
|
|
| 10 |
*/
|
| 11 |
export async function uploadInputFile(
|
| 12 |
userId: string,
|
| 13 |
-
|
| 14 |
fileName: string,
|
| 15 |
fileBuffer: Buffer,
|
|
|
|
| 16 |
): Promise<string> {
|
| 17 |
const ext = path.extname(fileName);
|
| 18 |
-
const storagePath = `${userId}/${
|
| 19 |
|
| 20 |
const { error } = await supabase.storage
|
| 21 |
.from(config.inputBucket)
|
| 22 |
.upload(storagePath, fileBuffer, {
|
| 23 |
contentType: getMimeType(ext),
|
| 24 |
-
upsert
|
| 25 |
});
|
| 26 |
|
| 27 |
if (error) {
|
|
@@ -84,6 +85,40 @@ export async function uploadReportPdf(
|
|
| 84 |
return { storagePath, expiresAt };
|
| 85 |
}
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
/**
|
| 88 |
* Delete a report PDF from Supabase Storage.
|
| 89 |
*/
|
|
|
|
| 10 |
*/
|
| 11 |
export async function uploadInputFile(
|
| 12 |
userId: string,
|
| 13 |
+
storageKey: string,
|
| 14 |
fileName: string,
|
| 15 |
fileBuffer: Buffer,
|
| 16 |
+
upsert = false,
|
| 17 |
): Promise<string> {
|
| 18 |
const ext = path.extname(fileName);
|
| 19 |
+
const storagePath = `${userId}/${storageKey}/input${ext}`;
|
| 20 |
|
| 21 |
const { error } = await supabase.storage
|
| 22 |
.from(config.inputBucket)
|
| 23 |
.upload(storagePath, fileBuffer, {
|
| 24 |
contentType: getMimeType(ext),
|
| 25 |
+
upsert,
|
| 26 |
});
|
| 27 |
|
| 28 |
if (error) {
|
|
|
|
| 85 |
return { storagePath, expiresAt };
|
| 86 |
}
|
| 87 |
|
| 88 |
+
/**
|
| 89 |
+
* Upload a legacy Turnitin Digital Receipt PDF with the same retention policy
|
| 90 |
+
* as the similarity report.
|
| 91 |
+
*/
|
| 92 |
+
export async function uploadReceiptPdf(
|
| 93 |
+
userId: string,
|
| 94 |
+
jobId: string,
|
| 95 |
+
localPdfPath: string,
|
| 96 |
+
): Promise<{ storagePath: string; expiresAt: string }> {
|
| 97 |
+
const storagePath = `${userId}/${jobId}/receipt.pdf`;
|
| 98 |
+
const fileBuffer = fs.readFileSync(localPdfPath);
|
| 99 |
+
|
| 100 |
+
const { error } = await supabase.storage
|
| 101 |
+
.from(config.reportBucket)
|
| 102 |
+
.upload(storagePath, fileBuffer, {
|
| 103 |
+
contentType: 'application/pdf',
|
| 104 |
+
upsert: true,
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
if (error) {
|
| 108 |
+
logger.error('Failed to upload Digital Receipt PDF', {
|
| 109 |
+
storagePath,
|
| 110 |
+
error: error.message,
|
| 111 |
+
});
|
| 112 |
+
throw error;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const expiresAt = new Date(
|
| 116 |
+
Date.now() + config.reportRetentionHours * 60 * 60 * 1000,
|
| 117 |
+
).toISOString();
|
| 118 |
+
|
| 119 |
+
return { storagePath, expiresAt };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
/**
|
| 123 |
* Delete a report PDF from Supabase Storage.
|
| 124 |
*/
|
src/db/tickets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { supabase } from './client';
|
| 2 |
import { logger } from '../utils/logger';
|
|
|
|
| 3 |
|
| 4 |
export interface CreateJobParams {
|
| 5 |
userId: string;
|
|
@@ -10,6 +11,12 @@ export interface CreateJobParams {
|
|
| 10 |
inputStoragePath: string;
|
| 11 |
inputFileSize?: number;
|
| 12 |
inputFileSha256?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
export interface UserProfile {
|
|
@@ -27,14 +34,17 @@ export interface UserProfile {
|
|
| 27 |
* Uses the database RPC to ensure ticket balance is checked and decremented in one transaction.
|
| 28 |
* Returns the new job ID.
|
| 29 |
*/
|
| 30 |
-
export async function createJobWithTicket(params: CreateJobParams): Promise<
|
| 31 |
-
const { data, error } = await supabase.rpc('
|
| 32 |
p_user_id: params.userId,
|
| 33 |
p_assignment_target_id: params.assignmentTargetId,
|
| 34 |
p_mode: params.mode,
|
| 35 |
p_filters: params.filters,
|
| 36 |
p_input_file_name: params.inputFileName,
|
| 37 |
p_input_file_path: params.inputStoragePath,
|
|
|
|
|
|
|
|
|
|
| 38 |
});
|
| 39 |
|
| 40 |
if (error) {
|
|
@@ -42,27 +52,37 @@ export async function createJobWithTicket(params: CreateJobParams): Promise<stri
|
|
| 42 |
throw error;
|
| 43 |
}
|
| 44 |
|
| 45 |
-
const
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
-
return
|
| 66 |
}
|
| 67 |
|
| 68 |
/**
|
|
|
|
| 1 |
import { supabase } from './client';
|
| 2 |
import { logger } from '../utils/logger';
|
| 3 |
+
import type { TurnitinJob } from './jobs';
|
| 4 |
|
| 5 |
export interface CreateJobParams {
|
| 6 |
userId: string;
|
|
|
|
| 11 |
inputStoragePath: string;
|
| 12 |
inputFileSize?: number;
|
| 13 |
inputFileSha256?: string;
|
| 14 |
+
submissionRequestId: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface CreateJobResult {
|
| 18 |
+
jobId: string;
|
| 19 |
+
created: boolean;
|
| 20 |
}
|
| 21 |
|
| 22 |
export interface UserProfile {
|
|
|
|
| 34 |
* Uses the database RPC to ensure ticket balance is checked and decremented in one transaction.
|
| 35 |
* Returns the new job ID.
|
| 36 |
*/
|
| 37 |
+
export async function createJobWithTicket(params: CreateJobParams): Promise<CreateJobResult> {
|
| 38 |
+
const { data, error } = await supabase.rpc('create_job_with_ticket_idempotent', {
|
| 39 |
p_user_id: params.userId,
|
| 40 |
p_assignment_target_id: params.assignmentTargetId,
|
| 41 |
p_mode: params.mode,
|
| 42 |
p_filters: params.filters,
|
| 43 |
p_input_file_name: params.inputFileName,
|
| 44 |
p_input_file_path: params.inputStoragePath,
|
| 45 |
+
p_input_file_size: params.inputFileSize ?? null,
|
| 46 |
+
p_input_file_sha256: params.inputFileSha256 ?? null,
|
| 47 |
+
p_submission_request_id: params.submissionRequestId,
|
| 48 |
});
|
| 49 |
|
| 50 |
if (error) {
|
|
|
|
| 52 |
throw error;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
const row = Array.isArray(data) ? data[0] : data;
|
| 56 |
+
if (!row?.job_id) {
|
| 57 |
+
throw new Error('Idempotent job creation returned no job ID');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
jobId: row.job_id as string,
|
| 62 |
+
created: row.created === true,
|
| 63 |
+
};
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function getJobBySubmissionRequestId(
|
| 67 |
+
userId: string,
|
| 68 |
+
submissionRequestId: string,
|
| 69 |
+
): Promise<TurnitinJob | null> {
|
| 70 |
+
const { data, error } = await supabase
|
| 71 |
+
.from('turnitin_jobs')
|
| 72 |
+
.select('*')
|
| 73 |
+
.eq('user_id', userId)
|
| 74 |
+
.eq('submission_request_id', submissionRequestId)
|
| 75 |
+
.maybeSingle();
|
| 76 |
+
|
| 77 |
+
if (error) {
|
| 78 |
+
logger.error('Failed to find job by submission request ID', {
|
| 79 |
+
userId,
|
| 80 |
+
error: error.message,
|
| 81 |
+
});
|
| 82 |
+
throw error;
|
| 83 |
}
|
| 84 |
|
| 85 |
+
return data as TurnitinJob | null;
|
| 86 |
}
|
| 87 |
|
| 88 |
/**
|
src/engine/legacy.ts
CHANGED
|
@@ -1579,12 +1579,45 @@ async function clickLegacyCartaElement(
|
|
| 1579 |
return false;
|
| 1580 |
}
|
| 1581 |
|
|
|
|
|
|
|
| 1582 |
async function downloadLegacyPdf(
|
| 1583 |
page: Page,
|
| 1584 |
context: BrowserContext,
|
| 1585 |
outputPath: string,
|
|
|
|
| 1586 |
): Promise<string> {
|
| 1587 |
let lastError: Error | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1588 |
|
| 1589 |
for (let attempt = 1; attempt <= 3; attempt++) {
|
| 1590 |
try {
|
|
@@ -1610,24 +1643,17 @@ async function downloadLegacyPdf(
|
|
| 1610 |
await page.waitForTimeout(1200);
|
| 1611 |
|
| 1612 |
const downloadPromise = waitForAnyDownload(context, 120000);
|
| 1613 |
-
const selected = await clickFirstVisible(page,
|
| 1614 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
'[role="menuitem"]:has-text("Current View")',
|
| 1621 |
-
], 8000) || await clickLegacyCartaElement(page, [
|
| 1622 |
-
'[data-px="EVSimReportDownloadCurrentView"]',
|
| 1623 |
-
'[aria-label="Current View"]',
|
| 1624 |
-
'.print-download-items [role="button"]',
|
| 1625 |
-
'.print-download-btn',
|
| 1626 |
-
'.sc-list-item-view',
|
| 1627 |
-
], ['current view'], 12000);
|
| 1628 |
|
| 1629 |
if (!selected) {
|
| 1630 |
-
|
|
|
|
| 1631 |
}
|
| 1632 |
|
| 1633 |
const download = await downloadPromise;
|
|
@@ -1642,6 +1668,7 @@ async function downloadLegacyPdf(
|
|
| 1642 |
lastError = error instanceof Error ? error : new Error(String(error));
|
| 1643 |
logger.warn('Legacy PDF download attempt failed', {
|
| 1644 |
attempt,
|
|
|
|
| 1645 |
error: lastError.message,
|
| 1646 |
});
|
| 1647 |
if (attempt < 3) {
|
|
@@ -1651,7 +1678,7 @@ async function downloadLegacyPdf(
|
|
| 1651 |
}
|
| 1652 |
}
|
| 1653 |
|
| 1654 |
-
throw lastError || new Error(
|
| 1655 |
}
|
| 1656 |
|
| 1657 |
export async function runLegacyTurnitinJob(
|
|
@@ -1698,7 +1725,7 @@ export async function runLegacyTurnitinJob(
|
|
| 1698 |
|
| 1699 |
const resumeFromViewer =
|
| 1700 |
resumeAfterStep &&
|
| 1701 |
-
['viewer', 'filters', 'download'].includes(resumeAfterStep) &&
|
| 1702 |
resumeViewerUrl;
|
| 1703 |
|
| 1704 |
if (resumeFromViewer) {
|
|
@@ -1798,12 +1825,34 @@ export async function runLegacyTurnitinJob(
|
|
| 1798 |
await emit(onEvent, 'info', 'download', 'Downloading legacy PDF report');
|
| 1799 |
fs.mkdirSync(outputDir, { recursive: true });
|
| 1800 |
const outputPdfPath = path.join(outputDir, `turnitin_legacy_report_${Date.now()}.pdf`);
|
| 1801 |
-
result.outputPdfPath = await downloadLegacyPdf(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1802 |
result.lastCompletedStep = 'download';
|
| 1803 |
await emit(onEvent, 'info', 'download', 'PDF downloaded successfully', {
|
| 1804 |
outputPdfPath: result.outputPdfPath,
|
| 1805 |
});
|
| 1806 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1807 |
const accountQuotaRemaining =
|
| 1808 |
typeof input.account.quotaRemaining === 'number'
|
| 1809 |
? input.account.quotaRemaining
|
|
|
|
| 1579 |
return false;
|
| 1580 |
}
|
| 1581 |
|
| 1582 |
+
type LegacyDownloadOption = 'current_view' | 'digital_receipt';
|
| 1583 |
+
|
| 1584 |
async function downloadLegacyPdf(
|
| 1585 |
page: Page,
|
| 1586 |
context: BrowserContext,
|
| 1587 |
outputPath: string,
|
| 1588 |
+
option: LegacyDownloadOption,
|
| 1589 |
): Promise<string> {
|
| 1590 |
let lastError: Error | null = null;
|
| 1591 |
+
const isReceipt = option === 'digital_receipt';
|
| 1592 |
+
const optionLabel = isReceipt ? 'Digital Receipt' : 'Current View';
|
| 1593 |
+
const optionSelectors = isReceipt
|
| 1594 |
+
? [
|
| 1595 |
+
'[data-px="EVSimReportDownloadDigitalReceipt"]',
|
| 1596 |
+
'[aria-label="Digital Receipt"]',
|
| 1597 |
+
'.print-download-items [role="button"]:has-text("Digital Receipt")',
|
| 1598 |
+
'.print-download-btn:has-text("Digital Receipt")',
|
| 1599 |
+
'button:has-text("Digital Receipt")',
|
| 1600 |
+
'a:has-text("Digital Receipt")',
|
| 1601 |
+
'[role="menuitem"]:has-text("Digital Receipt")',
|
| 1602 |
+
]
|
| 1603 |
+
: [
|
| 1604 |
+
'[data-px="EVSimReportDownloadCurrentView"]',
|
| 1605 |
+
'[aria-label="Current View"]',
|
| 1606 |
+
'.print-download-items [role="button"]:has-text("Current View")',
|
| 1607 |
+
'.print-download-btn:has-text("Current View")',
|
| 1608 |
+
'button:has-text("Current View")',
|
| 1609 |
+
'a:has-text("Current View")',
|
| 1610 |
+
'[role="menuitem"]:has-text("Current View")',
|
| 1611 |
+
];
|
| 1612 |
+
const cartaSelectors = isReceipt
|
| 1613 |
+
? [
|
| 1614 |
+
'[data-px="EVSimReportDownloadDigitalReceipt"]',
|
| 1615 |
+
'[aria-label="Digital Receipt"]',
|
| 1616 |
+
]
|
| 1617 |
+
: [
|
| 1618 |
+
'[data-px="EVSimReportDownloadCurrentView"]',
|
| 1619 |
+
'[aria-label="Current View"]',
|
| 1620 |
+
];
|
| 1621 |
|
| 1622 |
for (let attempt = 1; attempt <= 3; attempt++) {
|
| 1623 |
try {
|
|
|
|
| 1643 |
await page.waitForTimeout(1200);
|
| 1644 |
|
| 1645 |
const downloadPromise = waitForAnyDownload(context, 120000);
|
| 1646 |
+
const selected = await clickFirstVisible(page, optionSelectors, 8000) ||
|
| 1647 |
+
await clickLegacyCartaElement(
|
| 1648 |
+
page,
|
| 1649 |
+
cartaSelectors,
|
| 1650 |
+
[optionLabel.toLowerCase()],
|
| 1651 |
+
12000,
|
| 1652 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1653 |
|
| 1654 |
if (!selected) {
|
| 1655 |
+
void downloadPromise.catch(() => {});
|
| 1656 |
+
throw new Error(`Legacy ${optionLabel} download option was not found.`);
|
| 1657 |
}
|
| 1658 |
|
| 1659 |
const download = await downloadPromise;
|
|
|
|
| 1668 |
lastError = error instanceof Error ? error : new Error(String(error));
|
| 1669 |
logger.warn('Legacy PDF download attempt failed', {
|
| 1670 |
attempt,
|
| 1671 |
+
option,
|
| 1672 |
error: lastError.message,
|
| 1673 |
});
|
| 1674 |
if (attempt < 3) {
|
|
|
|
| 1678 |
}
|
| 1679 |
}
|
| 1680 |
|
| 1681 |
+
throw lastError || new Error(`Legacy ${optionLabel} PDF download failed.`);
|
| 1682 |
}
|
| 1683 |
|
| 1684 |
export async function runLegacyTurnitinJob(
|
|
|
|
| 1725 |
|
| 1726 |
const resumeFromViewer =
|
| 1727 |
resumeAfterStep &&
|
| 1728 |
+
['viewer', 'filters', 'download', 'receipt'].includes(resumeAfterStep) &&
|
| 1729 |
resumeViewerUrl;
|
| 1730 |
|
| 1731 |
if (resumeFromViewer) {
|
|
|
|
| 1825 |
await emit(onEvent, 'info', 'download', 'Downloading legacy PDF report');
|
| 1826 |
fs.mkdirSync(outputDir, { recursive: true });
|
| 1827 |
const outputPdfPath = path.join(outputDir, `turnitin_legacy_report_${Date.now()}.pdf`);
|
| 1828 |
+
result.outputPdfPath = await downloadLegacyPdf(
|
| 1829 |
+
page,
|
| 1830 |
+
context,
|
| 1831 |
+
outputPdfPath,
|
| 1832 |
+
'current_view',
|
| 1833 |
+
);
|
| 1834 |
result.lastCompletedStep = 'download';
|
| 1835 |
await emit(onEvent, 'info', 'download', 'PDF downloaded successfully', {
|
| 1836 |
outputPdfPath: result.outputPdfPath,
|
| 1837 |
});
|
| 1838 |
|
| 1839 |
+
// The Carta download modal closes after Current View is selected. Open the
|
| 1840 |
+
// Download panel again and fetch Digital Receipt as a separate PDF.
|
| 1841 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 1842 |
+
await page.waitForTimeout(500);
|
| 1843 |
+
await emit(onEvent, 'info', 'receipt', 'Downloading legacy Digital Receipt');
|
| 1844 |
+
const receiptPdfPath = path.join(outputDir, `turnitin_legacy_receipt_${Date.now()}.pdf`);
|
| 1845 |
+
result.receiptPdfPath = await downloadLegacyPdf(
|
| 1846 |
+
page,
|
| 1847 |
+
context,
|
| 1848 |
+
receiptPdfPath,
|
| 1849 |
+
'digital_receipt',
|
| 1850 |
+
);
|
| 1851 |
+
result.lastCompletedStep = 'receipt';
|
| 1852 |
+
await emit(onEvent, 'info', 'receipt', 'Digital Receipt downloaded successfully', {
|
| 1853 |
+
receiptPdfPath: result.receiptPdfPath,
|
| 1854 |
+
});
|
| 1855 |
+
|
| 1856 |
const accountQuotaRemaining =
|
| 1857 |
typeof input.account.quotaRemaining === 'number'
|
| 1858 |
? input.account.quotaRemaining
|
src/engine/turnitin.ts
CHANGED
|
@@ -79,6 +79,7 @@ export interface RunTurnitinJobResult {
|
|
| 79 |
viewerUrl?: string;
|
| 80 |
similarityPercent?: number;
|
| 81 |
outputPdfPath?: string;
|
|
|
|
| 82 |
submissionDetails?: SubmissionDetails;
|
| 83 |
quotaWarning?: string;
|
| 84 |
quotaLimit?: { limit: number; message: string; retryText?: string };
|
|
|
|
| 79 |
viewerUrl?: string;
|
| 80 |
similarityPercent?: number;
|
| 81 |
outputPdfPath?: string;
|
| 82 |
+
receiptPdfPath?: string;
|
| 83 |
submissionDetails?: SubmissionDetails;
|
| 84 |
quotaWarning?: string;
|
| 85 |
quotaLimit?: { limit: number; message: string; retryText?: string };
|
src/server/app.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function createApp(): Express {
|
|
| 26 |
res.setHeader('Vary', 'Origin');
|
| 27 |
}
|
| 28 |
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
| 29 |
-
res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-Admin-Secret');
|
| 30 |
res.setHeader('Access-Control-Max-Age', '86400');
|
| 31 |
res.setHeader('X-Content-Type-Options', 'nosniff');
|
| 32 |
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
|
|
| 26 |
res.setHeader('Vary', 'Origin');
|
| 27 |
}
|
| 28 |
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
| 29 |
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type,Idempotency-Key,X-Admin-Secret');
|
| 30 |
res.setHeader('Access-Control-Max-Age', '86400');
|
| 31 |
res.setHeader('X-Content-Type-Options', 'nosniff');
|
| 32 |
res.setHeader('Referrer-Policy', 'no-referrer');
|
src/server/routes/reports.ts
CHANGED
|
@@ -17,6 +17,16 @@ function buildReportDownloadName(inputFileName: string): string {
|
|
| 17 |
return `RelVDev_${base}.pdf`;
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
/**
|
| 21 |
* GET /api/jobs/:jobId/report-url
|
| 22 |
*
|
|
@@ -83,4 +93,68 @@ router.get(
|
|
| 83 |
},
|
| 84 |
);
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
export default router;
|
|
|
|
| 17 |
return `RelVDev_${base}.pdf`;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
function buildReceiptDownloadName(inputFileName: string): string {
|
| 21 |
+
const base = String(inputFileName || 'receipt')
|
| 22 |
+
.replace(/\.[^.]+$/, '')
|
| 23 |
+
.replace(/[\\/:*?"<>|]+/g, ' ')
|
| 24 |
+
.replace(/\s+/g, ' ')
|
| 25 |
+
.trim()
|
| 26 |
+
.slice(0, 132) || 'receipt';
|
| 27 |
+
return `RelVDev_Receipt_${base}.pdf`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
/**
|
| 31 |
* GET /api/jobs/:jobId/report-url
|
| 32 |
*
|
|
|
|
| 93 |
},
|
| 94 |
);
|
| 95 |
|
| 96 |
+
/**
|
| 97 |
+
* GET /api/jobs/:jobId/receipt-url
|
| 98 |
+
*
|
| 99 |
+
* Creates a short-lived signed URL for a legacy Digital Receipt PDF.
|
| 100 |
+
*/
|
| 101 |
+
router.get(
|
| 102 |
+
'/api/jobs/:jobId/receipt-url',
|
| 103 |
+
authenticateUser,
|
| 104 |
+
async (req, res: Response): Promise<void> => {
|
| 105 |
+
const authReq = req as AuthenticatedRequest;
|
| 106 |
+
const jobId = String(authReq.params.jobId || '');
|
| 107 |
+
|
| 108 |
+
try {
|
| 109 |
+
const job = await getJobById(jobId);
|
| 110 |
+
|
| 111 |
+
if (!job) {
|
| 112 |
+
res.status(404).json({ error: 'Job not found' });
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if (job.user_id !== authReq.userId) {
|
| 117 |
+
res.status(403).json({ error: 'Forbidden' });
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (!job.receipt_pdf_path) {
|
| 122 |
+
res.status(404).json({ error: 'Digital Receipt is not available yet' });
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (
|
| 127 |
+
job.receipt_pdf_expires_at &&
|
| 128 |
+
new Date(job.receipt_pdf_expires_at).getTime() < Date.now()
|
| 129 |
+
) {
|
| 130 |
+
res.status(410).json({ error: 'Digital Receipt has expired' });
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const fileName = buildReceiptDownloadName(job.input_file_name);
|
| 135 |
+
const signedUrl = await createSignedUrl(
|
| 136 |
+
config.reportBucket,
|
| 137 |
+
job.receipt_pdf_path,
|
| 138 |
+
300,
|
| 139 |
+
fileName,
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
res.json({
|
| 143 |
+
signedUrl,
|
| 144 |
+
fileName,
|
| 145 |
+
expiresIn: 300,
|
| 146 |
+
expiresAt: new Date(Date.now() + 300 * 1000).toISOString(),
|
| 147 |
+
});
|
| 148 |
+
} catch (err: unknown) {
|
| 149 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 150 |
+
logger.error('Failed to create Digital Receipt signed URL', {
|
| 151 |
+
jobId,
|
| 152 |
+
userId: authReq.userId,
|
| 153 |
+
error: message,
|
| 154 |
+
});
|
| 155 |
+
res.status(500).json({ error: 'Failed to create Digital Receipt download URL' });
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
export default router;
|
src/server/routes/submit.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { Router, Response } from 'express';
|
|
| 2 |
import multer from 'multer';
|
| 3 |
import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
|
| 4 |
import { uploadInputFile } from '../../db/storage';
|
| 5 |
-
import {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
import { logger } from '../../utils/logger';
|
| 8 |
-
import { createHash } from 'crypto';
|
| 9 |
|
| 10 |
const router = Router();
|
| 11 |
|
|
@@ -29,6 +32,7 @@ const ALLOWED_EXTENSIONS = new Set([
|
|
| 29 |
'.txt',
|
| 30 |
]);
|
| 31 |
const ALLOWED_MODES = new Set(['upload', 'resubmit']);
|
|
|
|
| 32 |
|
| 33 |
const DEFAULT_FILTERS: Record<string, unknown> = {
|
| 34 |
excludeBibliography: false,
|
|
@@ -44,6 +48,31 @@ function getFileExtension(filename: string): string {
|
|
| 44 |
return lastDot >= 0 ? filename.slice(lastDot).toLowerCase() : '';
|
| 45 |
}
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
/**
|
| 48 |
* POST /api/submit
|
| 49 |
* Accepts a file upload + metadata, validates, stores the file,
|
|
@@ -58,7 +87,12 @@ router.post(
|
|
| 58 |
|
| 59 |
try {
|
| 60 |
// 1. Validate required fields
|
| 61 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
if (!assignment_target_id || !mode) {
|
| 64 |
res.status(400).json({
|
|
@@ -74,6 +108,16 @@ router.post(
|
|
| 74 |
return;
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
// Parse filters (may come as stringified JSON)
|
| 78 |
let filters: Record<string, unknown>;
|
| 79 |
try {
|
|
@@ -125,22 +169,57 @@ router.post(
|
|
| 125 |
filters.smallMatchThreshold = null;
|
| 126 |
}
|
| 127 |
|
| 128 |
-
// 5.
|
| 129 |
const inputFileSha256 = createHash('sha256')
|
| 130 |
.update(authReq.file.buffer)
|
| 131 |
.digest('hex');
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
const storagePath = await uploadInputFile(
|
| 134 |
authReq.userId,
|
| 135 |
-
/
|
| 136 |
-
// We use a timestamp-based placeholder, then the RPC returns the real job ID
|
| 137 |
-
Date.now().toString(36),
|
| 138 |
authReq.file.originalname,
|
| 139 |
authReq.file.buffer,
|
|
|
|
| 140 |
);
|
| 141 |
|
| 142 |
-
// 6.
|
| 143 |
-
|
|
|
|
| 144 |
userId: authReq.userId,
|
| 145 |
assignmentTargetId: assignment_target_id,
|
| 146 |
mode,
|
|
@@ -149,20 +228,26 @@ router.post(
|
|
| 149 |
inputStoragePath: storagePath,
|
| 150 |
inputFileSize: authReq.file.size,
|
| 151 |
inputFileSha256,
|
|
|
|
| 152 |
});
|
| 153 |
|
| 154 |
// Fetch updated ticket balance
|
| 155 |
const profile = await getUserProfile(authReq.userId);
|
| 156 |
const ticketBalance = profile?.ticket_balance ?? 0;
|
| 157 |
|
| 158 |
-
logger.info('Job submitted successfully', {
|
| 159 |
-
jobId,
|
| 160 |
userId: authReq.userId,
|
| 161 |
fileName: authReq.file.originalname,
|
|
|
|
| 162 |
});
|
| 163 |
|
| 164 |
// 7. Return success
|
| 165 |
-
res.status(201).json({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
} catch (err: unknown) {
|
| 167 |
const message = err instanceof Error ? err.message : String(err);
|
| 168 |
|
|
@@ -172,6 +257,11 @@ router.post(
|
|
| 172 |
return;
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
logger.error('Submit endpoint error', {
|
| 176 |
userId: authReq.userId,
|
| 177 |
error: message,
|
|
|
|
| 2 |
import multer from 'multer';
|
| 3 |
import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
|
| 4 |
import { uploadInputFile } from '../../db/storage';
|
| 5 |
+
import {
|
| 6 |
+
createJobWithTicket,
|
| 7 |
+
getJobBySubmissionRequestId,
|
| 8 |
+
getUserProfile,
|
| 9 |
+
} from '../../db/tickets';
|
| 10 |
import { logger } from '../../utils/logger';
|
| 11 |
+
import { createHash, randomUUID } from 'crypto';
|
| 12 |
|
| 13 |
const router = Router();
|
| 14 |
|
|
|
|
| 32 |
'.txt',
|
| 33 |
]);
|
| 34 |
const ALLOWED_MODES = new Set(['upload', 'resubmit']);
|
| 35 |
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
| 36 |
|
| 37 |
const DEFAULT_FILTERS: Record<string, unknown> = {
|
| 38 |
excludeBibliography: false,
|
|
|
|
| 48 |
return lastDot >= 0 ? filename.slice(lastDot).toLowerCase() : '';
|
| 49 |
}
|
| 50 |
|
| 51 |
+
function existingJobMatchesRequest(
|
| 52 |
+
existingJob: {
|
| 53 |
+
assignment_target_id: string;
|
| 54 |
+
mode: string;
|
| 55 |
+
input_file_name: string;
|
| 56 |
+
input_file_size: number | null;
|
| 57 |
+
input_file_sha256: string | null;
|
| 58 |
+
},
|
| 59 |
+
request: {
|
| 60 |
+
assignmentTargetId: string;
|
| 61 |
+
mode: string;
|
| 62 |
+
inputFileName: string;
|
| 63 |
+
inputFileSize: number;
|
| 64 |
+
inputFileSha256: string;
|
| 65 |
+
},
|
| 66 |
+
): boolean {
|
| 67 |
+
return (
|
| 68 |
+
existingJob.assignment_target_id === request.assignmentTargetId &&
|
| 69 |
+
existingJob.mode === request.mode &&
|
| 70 |
+
existingJob.input_file_name === request.inputFileName &&
|
| 71 |
+
existingJob.input_file_size === request.inputFileSize &&
|
| 72 |
+
existingJob.input_file_sha256 === request.inputFileSha256
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
/**
|
| 77 |
* POST /api/submit
|
| 78 |
* Accepts a file upload + metadata, validates, stores the file,
|
|
|
|
| 87 |
|
| 88 |
try {
|
| 89 |
// 1. Validate required fields
|
| 90 |
+
const {
|
| 91 |
+
assignment_target_id,
|
| 92 |
+
mode,
|
| 93 |
+
filters: filtersRaw,
|
| 94 |
+
submission_request_id: bodySubmissionRequestId,
|
| 95 |
+
} = authReq.body;
|
| 96 |
|
| 97 |
if (!assignment_target_id || !mode) {
|
| 98 |
res.status(400).json({
|
|
|
|
| 108 |
return;
|
| 109 |
}
|
| 110 |
|
| 111 |
+
const suppliedRequestId = String(
|
| 112 |
+
bodySubmissionRequestId || authReq.get('Idempotency-Key') || '',
|
| 113 |
+
).trim();
|
| 114 |
+
const submissionRequestId = suppliedRequestId || randomUUID();
|
| 115 |
+
|
| 116 |
+
if (!UUID_PATTERN.test(submissionRequestId)) {
|
| 117 |
+
res.status(400).json({ error: 'Invalid submission request ID' });
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
// Parse filters (may come as stringified JSON)
|
| 122 |
let filters: Record<string, unknown>;
|
| 123 |
try {
|
|
|
|
| 169 |
filters.smallMatchThreshold = null;
|
| 170 |
}
|
| 171 |
|
| 172 |
+
// 5. Fingerprint the payload before any state-changing operation.
|
| 173 |
const inputFileSha256 = createHash('sha256')
|
| 174 |
.update(authReq.file.buffer)
|
| 175 |
.digest('hex');
|
| 176 |
|
| 177 |
+
const existingJob = await getJobBySubmissionRequestId(
|
| 178 |
+
authReq.userId,
|
| 179 |
+
submissionRequestId,
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
+
if (existingJob) {
|
| 183 |
+
if (!existingJobMatchesRequest(existingJob, {
|
| 184 |
+
assignmentTargetId: String(assignment_target_id),
|
| 185 |
+
mode: String(mode),
|
| 186 |
+
inputFileName: authReq.file.originalname,
|
| 187 |
+
inputFileSize: authReq.file.size,
|
| 188 |
+
inputFileSha256,
|
| 189 |
+
})) {
|
| 190 |
+
res.status(409).json({
|
| 191 |
+
error: 'Submission request ID was already used for a different file or configuration',
|
| 192 |
+
});
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const profile = await getUserProfile(authReq.userId);
|
| 197 |
+
logger.info('Idempotent submit replay returned existing job', {
|
| 198 |
+
jobId: existingJob.id,
|
| 199 |
+
userId: authReq.userId,
|
| 200 |
+
submissionRequestId,
|
| 201 |
+
});
|
| 202 |
+
res.status(200).json({
|
| 203 |
+
jobId: existingJob.id,
|
| 204 |
+
ticketBalance: profile?.ticket_balance ?? 0,
|
| 205 |
+
idempotentReplay: true,
|
| 206 |
+
});
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// A deterministic request/hash path makes concurrent retries upload the
|
| 211 |
+
// same bytes to the same object before the database invariant resolves them.
|
| 212 |
const storagePath = await uploadInputFile(
|
| 213 |
authReq.userId,
|
| 214 |
+
`${submissionRequestId}/${inputFileSha256}`,
|
|
|
|
|
|
|
| 215 |
authReq.file.originalname,
|
| 216 |
authReq.file.buffer,
|
| 217 |
+
true,
|
| 218 |
);
|
| 219 |
|
| 220 |
+
// 6. Atomically create one job/ticket ledger entry, or return the job
|
| 221 |
+
// already created by another Space handling this exact request.
|
| 222 |
+
const creation = await createJobWithTicket({
|
| 223 |
userId: authReq.userId,
|
| 224 |
assignmentTargetId: assignment_target_id,
|
| 225 |
mode,
|
|
|
|
| 228 |
inputStoragePath: storagePath,
|
| 229 |
inputFileSize: authReq.file.size,
|
| 230 |
inputFileSha256,
|
| 231 |
+
submissionRequestId,
|
| 232 |
});
|
| 233 |
|
| 234 |
// Fetch updated ticket balance
|
| 235 |
const profile = await getUserProfile(authReq.userId);
|
| 236 |
const ticketBalance = profile?.ticket_balance ?? 0;
|
| 237 |
|
| 238 |
+
logger.info(creation.created ? 'Job submitted successfully' : 'Idempotent submit race resolved', {
|
| 239 |
+
jobId: creation.jobId,
|
| 240 |
userId: authReq.userId,
|
| 241 |
fileName: authReq.file.originalname,
|
| 242 |
+
submissionRequestId,
|
| 243 |
});
|
| 244 |
|
| 245 |
// 7. Return success
|
| 246 |
+
res.status(creation.created ? 201 : 200).json({
|
| 247 |
+
jobId: creation.jobId,
|
| 248 |
+
ticketBalance,
|
| 249 |
+
idempotentReplay: !creation.created,
|
| 250 |
+
});
|
| 251 |
} catch (err: unknown) {
|
| 252 |
const message = err instanceof Error ? err.message : String(err);
|
| 253 |
|
|
|
|
| 257 |
return;
|
| 258 |
}
|
| 259 |
|
| 260 |
+
if (message.includes('Idempotency key')) {
|
| 261 |
+
res.status(409).json({ error: 'Submission request ID conflict' });
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
logger.error('Submit endpoint error', {
|
| 266 |
userId: authReq.userId,
|
| 267 |
error: message,
|
src/worker/manager.ts
CHANGED
|
@@ -6,18 +6,19 @@ import { getAccountPassword } from '../crypto/password';
|
|
| 6 |
import { supabase } from '../db/client';
|
| 7 |
|
| 8 |
// DB imports (will be created by subagent)
|
| 9 |
-
import { claimPendingJob, updateJobStatus, updateJobFields, incrementJobAttempt, getJobById, TurnitinJob } from '../db/jobs';
|
| 10 |
import {
|
| 11 |
claimAvailableAccount,
|
| 12 |
claimSpecificAccountForResume,
|
| 13 |
countAvailableAccounts,
|
| 14 |
getAccountPoolState,
|
|
|
|
| 15 |
releaseAccount,
|
| 16 |
updateAccountQuota,
|
| 17 |
type TurnitinAccount,
|
| 18 |
} from '../db/accounts';
|
| 19 |
import { insertJobEvent } from '../db/events';
|
| 20 |
-
import { downloadInputFile, uploadReportPdf, downloadStorageState, uploadStorageState } from '../db/storage';
|
| 21 |
import { cancelJob, refundFailedJob } from '../db/tickets';
|
| 22 |
import { MODERN_ONE_POOL_KEY, runTurnitinJob, RunTurnitinJobInput, RunTurnitinJobResult } from '../engine/turnitin';
|
| 23 |
|
|
@@ -30,7 +31,7 @@ let running = false;
|
|
| 30 |
|
| 31 |
const ACCOUNT_WAIT_POLL_MS = Number(process.env.ACCOUNT_WAIT_POLL_MS || 15000);
|
| 32 |
const ACCOUNT_WAIT_MAX_MS = Number(process.env.ACCOUNT_WAIT_MAX_MS || 30 * 60 * 1000);
|
| 33 |
-
const RESUME_PROTECTED_STEPS = ['submitted', 'similarity', 'viewer', 'filters', 'download'];
|
| 34 |
const RESUME_ACCOUNT_RETRY_DELAY_MS = Number(process.env.RESUME_ACCOUNT_RETRY_DELAY_MS || 30000);
|
| 35 |
const DEFAULT_ACCOUNT_POOL_KEY = 'modern_lti';
|
| 36 |
const LEGACY_ACCOUNT_POOL_KEY = 'legacy_carta';
|
|
@@ -331,15 +332,6 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 331 |
RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) &&
|
| 332 |
freshJob.identity_id,
|
| 333 |
);
|
| 334 |
-
const retryShouldPreferPreviousAccount = Boolean(
|
| 335 |
-
!resumeNeedsSameAccount &&
|
| 336 |
-
currentAttemptCount > 1 &&
|
| 337 |
-
freshJob.identity_id &&
|
| 338 |
-
!isAccountTerminalError(freshJob.error_message),
|
| 339 |
-
);
|
| 340 |
-
const shouldClaimPreviousAccount =
|
| 341 |
-
resumeNeedsSameAccount || retryShouldPreferPreviousAccount;
|
| 342 |
-
|
| 343 |
assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog);
|
| 344 |
accountPoolKey =
|
| 345 |
assignmentTarget.accountPoolKey ||
|
|
@@ -352,15 +344,31 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 352 |
uiVariant: assignmentTarget.uiVariant,
|
| 353 |
});
|
| 354 |
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId)
|
| 357 |
: await claimAccountForJob(job, identityId, jobLog, accountPoolKey);
|
| 358 |
|
| 359 |
if (!account) {
|
| 360 |
-
if (
|
| 361 |
-
const
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
await updateJobStatus(jobId, 'waiting_account', {
|
| 365 |
error_message: message,
|
| 366 |
attempt_count: Math.max(0, currentAttemptCount - 1),
|
|
@@ -459,7 +467,7 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 459 |
// again. The engine's `resumeAfterStep` tells it to skip earlier steps.
|
| 460 |
const lastCompletedStep = initialLastCompletedStep;
|
| 461 |
const effectiveMode =
|
| 462 |
-
lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download'].includes(lastCompletedStep)
|
| 463 |
? 'resubmit' as const // force resubmit because file is already there
|
| 464 |
: (job.mode as 'upload' | 'resubmit' | 'quota_check');
|
| 465 |
|
|
@@ -563,6 +571,26 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 563 |
await emitEvent(jobId, identityId, 'info', 'pdf_uploaded', 'PDF report uploaded to storage');
|
| 564 |
}
|
| 565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
// Save storage state for session reuse
|
| 567 |
try {
|
| 568 |
// The engine should have saved the storage state; we read and upload it
|
|
@@ -578,15 +606,31 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 578 |
jobLog.warn('Failed to save storage state');
|
| 579 |
}
|
| 580 |
|
| 581 |
-
//
|
| 582 |
-
|
|
|
|
|
|
|
| 583 |
viewer_url: result.viewerUrl,
|
| 584 |
similarity_percent: result.similarityPercent,
|
| 585 |
output_pdf_path: outputPdfPath,
|
| 586 |
output_pdf_expires_at: outputPdfExpiresAt,
|
|
|
|
|
|
|
| 587 |
error_message: null,
|
| 588 |
finished_at: new Date().toISOString(),
|
| 589 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
if (result.submissionDetails) {
|
| 591 |
await safeUpdateJobFields(jobId, {
|
| 592 |
submission_details: result.submissionDetails as Record<string, unknown>,
|
|
@@ -610,6 +654,8 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 610 |
filters_applied: job.filters,
|
| 611 |
pdf_path: outputPdfPath,
|
| 612 |
pdf_expires_at: outputPdfExpiresAt,
|
|
|
|
|
|
|
| 613 |
submitted_at: result.submittedAt,
|
| 614 |
});
|
| 615 |
} catch (err) {
|
|
@@ -699,11 +745,22 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 699 |
}
|
| 700 |
}
|
| 701 |
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
|
| 708 |
// Cleanup temp directory (moved to finally, see below)
|
| 709 |
} catch (err) {
|
|
@@ -774,6 +831,15 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 774 |
: null;
|
| 775 |
const remainingAfterConsumedSubmit =
|
| 776 |
knownRemaining === null ? null : Math.max(0, knownRemaining - 1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
|
| 778 |
// Release account if claimed
|
| 779 |
if (identityId) {
|
|
@@ -817,9 +883,11 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 817 |
if (isPostSubmitResume) {
|
| 818 |
if (shouldRetry) {
|
| 819 |
nextStatus = 'cooling_down';
|
| 820 |
-
} else if (
|
|
|
|
|
|
|
| 821 |
nextStatus = 'quota_limited';
|
| 822 |
-
} else if (
|
| 823 |
nextStatus = 'cooling_down';
|
| 824 |
}
|
| 825 |
}
|
|
@@ -829,11 +897,15 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 829 |
turnitin_status: nextStatus,
|
| 830 |
turnitin_quota_remaining: remainingAfterConsumedSubmit,
|
| 831 |
turnitin_quota_message:
|
| 832 |
-
|
|
|
|
|
|
|
| 833 |
? 'Quota needs refresh after a failed post-submit attempt.'
|
| 834 |
: null,
|
| 835 |
turnitin_next_retry_at:
|
| 836 |
-
|
|
|
|
|
|
|
| 837 |
? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
| 838 |
: null,
|
| 839 |
turnitin_last_error: errorMessage,
|
|
@@ -841,9 +913,26 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 841 |
} else if (nextStatus === 'cooling_down') {
|
| 842 |
await updateAccountQuota(identityId, {
|
| 843 |
turnitin_status: 'cooling_down',
|
|
|
|
|
|
|
|
|
|
| 844 |
turnitin_last_error: errorMessage,
|
| 845 |
turnitin_quota_message:
|
| 846 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 847 |
}).catch(() => {});
|
| 848 |
}
|
| 849 |
await releaseAccount(identityId, nextStatus, errorMessage);
|
|
@@ -853,6 +942,16 @@ async function processJob(job: TurnitinJob): Promise<void> {
|
|
| 853 |
}
|
| 854 |
}
|
| 855 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
// Check if we should retry
|
| 857 |
if (shouldRetry) {
|
| 858 |
await updateJobStatus(jobId, 'pending', {
|
|
@@ -1069,13 +1168,6 @@ function compactWorkerMessage(message: string): string {
|
|
| 1069 |
return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
|
| 1070 |
}
|
| 1071 |
|
| 1072 |
-
function isAccountTerminalError(message: string | null | undefined): boolean {
|
| 1073 |
-
const value = String(message || '');
|
| 1074 |
-
return /login|reached your limit|submission quota limit|target class|assignment is not available|class.*not found|assignment.*not found/i.test(
|
| 1075 |
-
value,
|
| 1076 |
-
);
|
| 1077 |
-
}
|
| 1078 |
-
|
| 1079 |
/**
|
| 1080 |
* Helper to emit a job event.
|
| 1081 |
*/
|
|
@@ -1160,6 +1252,12 @@ async function persistJobProgressFromEvent(
|
|
| 1160 |
/PDF downloaded successfully/i.test(event.message)
|
| 1161 |
) {
|
| 1162 |
fields.last_completed_step = 'download';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1163 |
}
|
| 1164 |
|
| 1165 |
if (Object.keys(fields).length > 0) {
|
|
|
|
| 6 |
import { supabase } from '../db/client';
|
| 7 |
|
| 8 |
// DB imports (will be created by subagent)
|
| 9 |
+
import { claimPendingJob, completeJobIfActive, updateJobStatus, updateJobFields, incrementJobAttempt, getJobById, TurnitinJob } from '../db/jobs';
|
| 10 |
import {
|
| 11 |
claimAvailableAccount,
|
| 12 |
claimSpecificAccountForResume,
|
| 13 |
countAvailableAccounts,
|
| 14 |
getAccountPoolState,
|
| 15 |
+
getTurnitinAccountById,
|
| 16 |
releaseAccount,
|
| 17 |
updateAccountQuota,
|
| 18 |
type TurnitinAccount,
|
| 19 |
} from '../db/accounts';
|
| 20 |
import { insertJobEvent } from '../db/events';
|
| 21 |
+
import { downloadInputFile, uploadReceiptPdf, uploadReportPdf, downloadStorageState, uploadStorageState } from '../db/storage';
|
| 22 |
import { cancelJob, refundFailedJob } from '../db/tickets';
|
| 23 |
import { MODERN_ONE_POOL_KEY, runTurnitinJob, RunTurnitinJobInput, RunTurnitinJobResult } from '../engine/turnitin';
|
| 24 |
|
|
|
|
| 31 |
|
| 32 |
const ACCOUNT_WAIT_POLL_MS = Number(process.env.ACCOUNT_WAIT_POLL_MS || 15000);
|
| 33 |
const ACCOUNT_WAIT_MAX_MS = Number(process.env.ACCOUNT_WAIT_MAX_MS || 30 * 60 * 1000);
|
| 34 |
+
const RESUME_PROTECTED_STEPS = ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'];
|
| 35 |
const RESUME_ACCOUNT_RETRY_DELAY_MS = Number(process.env.RESUME_ACCOUNT_RETRY_DELAY_MS || 30000);
|
| 36 |
const DEFAULT_ACCOUNT_POOL_KEY = 'modern_lti';
|
| 37 |
const LEGACY_ACCOUNT_POOL_KEY = 'legacy_carta';
|
|
|
|
| 332 |
RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) &&
|
| 333 |
freshJob.identity_id,
|
| 334 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog);
|
| 336 |
accountPoolKey =
|
| 337 |
assignmentTarget.accountPoolKey ||
|
|
|
|
| 344 |
uiVariant: assignmentTarget.uiVariant,
|
| 345 |
});
|
| 346 |
|
| 347 |
+
// Before submission, every retry may safely rotate to another account.
|
| 348 |
+
// Keeping a pre-submit job attached to an account that has since become
|
| 349 |
+
// quota_limited makes it wait forever even when the pool has free accounts.
|
| 350 |
+
// Post-submit checkpoints remain pinned to the original account to avoid
|
| 351 |
+
// uploading the same file again.
|
| 352 |
+
const account = resumeNeedsSameAccount
|
| 353 |
? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId)
|
| 354 |
: await claimAccountForJob(job, identityId, jobLog, accountPoolKey);
|
| 355 |
|
| 356 |
if (!account) {
|
| 357 |
+
if (resumeNeedsSameAccount) {
|
| 358 |
+
const previousAccount = await getTurnitinAccountById(
|
| 359 |
+
freshJob.identity_id as string,
|
| 360 |
+
).catch(() => null);
|
| 361 |
+
if (
|
| 362 |
+
!previousAccount ||
|
| 363 |
+
['disabled', 'login_failed'].includes(previousAccount.turnitin_status)
|
| 364 |
+
) {
|
| 365 |
+
throw new Error(
|
| 366 |
+
'The Turnitin account that owns the submitted file is no longer available for report recovery.',
|
| 367 |
+
);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const message =
|
| 371 |
+
'Previous submission is still locked by its Turnitin account; retrying shortly.';
|
| 372 |
await updateJobStatus(jobId, 'waiting_account', {
|
| 373 |
error_message: message,
|
| 374 |
attempt_count: Math.max(0, currentAttemptCount - 1),
|
|
|
|
| 467 |
// again. The engine's `resumeAfterStep` tells it to skip earlier steps.
|
| 468 |
const lastCompletedStep = initialLastCompletedStep;
|
| 469 |
const effectiveMode =
|
| 470 |
+
lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'].includes(lastCompletedStep)
|
| 471 |
? 'resubmit' as const // force resubmit because file is already there
|
| 472 |
: (job.mode as 'upload' | 'resubmit' | 'quota_check');
|
| 473 |
|
|
|
|
| 571 |
await emitEvent(jobId, identityId, 'info', 'pdf_uploaded', 'PDF report uploaded to storage');
|
| 572 |
}
|
| 573 |
|
| 574 |
+
let receiptPdfPath: string | undefined;
|
| 575 |
+
let receiptPdfExpiresAt: string | undefined;
|
| 576 |
+
|
| 577 |
+
if (result.receiptPdfPath && fs.existsSync(result.receiptPdfPath)) {
|
| 578 |
+
const uploadResult = await uploadReceiptPdf(
|
| 579 |
+
job.user_id as string,
|
| 580 |
+
jobId,
|
| 581 |
+
result.receiptPdfPath,
|
| 582 |
+
);
|
| 583 |
+
receiptPdfPath = uploadResult.storagePath;
|
| 584 |
+
receiptPdfExpiresAt = uploadResult.expiresAt;
|
| 585 |
+
await emitEvent(
|
| 586 |
+
jobId,
|
| 587 |
+
identityId,
|
| 588 |
+
'info',
|
| 589 |
+
'receipt_uploaded',
|
| 590 |
+
'Digital Receipt uploaded to storage',
|
| 591 |
+
);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
// Save storage state for session reuse
|
| 595 |
try {
|
| 596 |
// The engine should have saved the storage state; we read and upload it
|
|
|
|
| 606 |
jobLog.warn('Failed to save storage state');
|
| 607 |
}
|
| 608 |
|
| 609 |
+
// An administrator may fail/cancel a job while Playwright is already in
|
| 610 |
+
// the viewer. Persist completion only if the job is still active so an
|
| 611 |
+
// in-flight callback cannot revive a terminal job.
|
| 612 |
+
const completionPersisted = await completeJobIfActive(jobId, {
|
| 613 |
viewer_url: result.viewerUrl,
|
| 614 |
similarity_percent: result.similarityPercent,
|
| 615 |
output_pdf_path: outputPdfPath,
|
| 616 |
output_pdf_expires_at: outputPdfExpiresAt,
|
| 617 |
+
receipt_pdf_path: receiptPdfPath,
|
| 618 |
+
receipt_pdf_expires_at: receiptPdfExpiresAt,
|
| 619 |
error_message: null,
|
| 620 |
finished_at: new Date().toISOString(),
|
| 621 |
});
|
| 622 |
+
if (!completionPersisted) {
|
| 623 |
+
const terminalArtifacts: Partial<TurnitinJob> = {};
|
| 624 |
+
if (outputPdfPath) terminalArtifacts.output_pdf_path = outputPdfPath;
|
| 625 |
+
if (outputPdfExpiresAt) terminalArtifacts.output_pdf_expires_at = outputPdfExpiresAt;
|
| 626 |
+
if (receiptPdfPath) terminalArtifacts.receipt_pdf_path = receiptPdfPath;
|
| 627 |
+
if (receiptPdfExpiresAt) {
|
| 628 |
+
terminalArtifacts.receipt_pdf_expires_at = receiptPdfExpiresAt;
|
| 629 |
+
}
|
| 630 |
+
if (Object.keys(terminalArtifacts).length > 0) {
|
| 631 |
+
await safeUpdateJobFields(jobId, terminalArtifacts);
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
if (result.submissionDetails) {
|
| 635 |
await safeUpdateJobFields(jobId, {
|
| 636 |
submission_details: result.submissionDetails as Record<string, unknown>,
|
|
|
|
| 654 |
filters_applied: job.filters,
|
| 655 |
pdf_path: outputPdfPath,
|
| 656 |
pdf_expires_at: outputPdfExpiresAt,
|
| 657 |
+
receipt_pdf_path: receiptPdfPath,
|
| 658 |
+
receipt_pdf_expires_at: receiptPdfExpiresAt,
|
| 659 |
submitted_at: result.submittedAt,
|
| 660 |
});
|
| 661 |
} catch (err) {
|
|
|
|
| 745 |
}
|
| 746 |
}
|
| 747 |
|
| 748 |
+
if (completionPersisted) {
|
| 749 |
+
await emitEvent(jobId, identityId, 'info', 'completed', `Job completed. Similarity: ${result.similarityPercent ?? 'N/A'}%`);
|
| 750 |
+
jobLog.info('Job completed successfully', {
|
| 751 |
+
similarity: result.similarityPercent,
|
| 752 |
+
viewerUrl: result.viewerUrl,
|
| 753 |
+
});
|
| 754 |
+
} else {
|
| 755 |
+
await emitEvent(
|
| 756 |
+
jobId,
|
| 757 |
+
identityId,
|
| 758 |
+
'warning',
|
| 759 |
+
'terminal_status_preserved',
|
| 760 |
+
'Worker cleanup finished after the job was stopped; terminal status preserved.',
|
| 761 |
+
);
|
| 762 |
+
jobLog.warn('Worker finished after job entered a terminal state; completion was not persisted');
|
| 763 |
+
}
|
| 764 |
|
| 765 |
// Cleanup temp directory (moved to finally, see below)
|
| 766 |
} catch (err) {
|
|
|
|
| 831 |
: null;
|
| 832 |
const remainingAfterConsumedSubmit =
|
| 833 |
knownRemaining === null ? null : Math.max(0, knownRemaining - 1);
|
| 834 |
+
const remainingAfterJob = submissionConsumedThisAttempt
|
| 835 |
+
? remainingAfterConsumedSubmit
|
| 836 |
+
: knownRemaining;
|
| 837 |
+
const legacyNeedsClassCleanup = Boolean(
|
| 838 |
+
accountPoolKey === LEGACY_ACCOUNT_POOL_KEY &&
|
| 839 |
+
isPostSubmitResume &&
|
| 840 |
+
!shouldRetry &&
|
| 841 |
+
remainingAfterJob === 0,
|
| 842 |
+
);
|
| 843 |
|
| 844 |
// Release account if claimed
|
| 845 |
if (identityId) {
|
|
|
|
| 883 |
if (isPostSubmitResume) {
|
| 884 |
if (shouldRetry) {
|
| 885 |
nextStatus = 'cooling_down';
|
| 886 |
+
} else if (legacyNeedsClassCleanup) {
|
| 887 |
+
nextStatus = 'cooling_down';
|
| 888 |
+
} else if (remainingAfterJob === 0) {
|
| 889 |
nextStatus = 'quota_limited';
|
| 890 |
+
} else if (remainingAfterJob === null) {
|
| 891 |
nextStatus = 'cooling_down';
|
| 892 |
}
|
| 893 |
}
|
|
|
|
| 897 |
turnitin_status: nextStatus,
|
| 898 |
turnitin_quota_remaining: remainingAfterConsumedSubmit,
|
| 899 |
turnitin_quota_message:
|
| 900 |
+
legacyNeedsClassCleanup
|
| 901 |
+
? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.'
|
| 902 |
+
: remainingAfterConsumedSubmit === null
|
| 903 |
? 'Quota needs refresh after a failed post-submit attempt.'
|
| 904 |
: null,
|
| 905 |
turnitin_next_retry_at:
|
| 906 |
+
legacyNeedsClassCleanup
|
| 907 |
+
? new Date().toISOString()
|
| 908 |
+
: nextStatus === 'cooling_down' || nextStatus === 'quota_limited'
|
| 909 |
? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
| 910 |
: null,
|
| 911 |
turnitin_last_error: errorMessage,
|
|
|
|
| 913 |
} else if (nextStatus === 'cooling_down') {
|
| 914 |
await updateAccountQuota(identityId, {
|
| 915 |
turnitin_status: 'cooling_down',
|
| 916 |
+
turnitin_next_retry_at: legacyNeedsClassCleanup
|
| 917 |
+
? new Date().toISOString()
|
| 918 |
+
: claimedAccount?.turnitin_next_retry_at,
|
| 919 |
turnitin_last_error: errorMessage,
|
| 920 |
turnitin_quota_message:
|
| 921 |
+
legacyNeedsClassCleanup
|
| 922 |
+
? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.'
|
| 923 |
+
: 'Reserved for retry after submitted file reached report viewer.',
|
| 924 |
+
}).catch(() => {});
|
| 925 |
+
} else if (nextStatus === 'quota_limited') {
|
| 926 |
+
await updateAccountQuota(identityId, {
|
| 927 |
+
turnitin_status: 'quota_limited',
|
| 928 |
+
turnitin_quota_remaining: 0,
|
| 929 |
+
turnitin_quota_message:
|
| 930 |
+
'Submitted file consumed the remaining quota before report processing failed.',
|
| 931 |
+
turnitin_next_retry_at:
|
| 932 |
+
accountPoolKey === MODERN_ONE_POOL_KEY
|
| 933 |
+
? null
|
| 934 |
+
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 935 |
+
turnitin_last_error: errorMessage,
|
| 936 |
}).catch(() => {});
|
| 937 |
}
|
| 938 |
await releaseAccount(identityId, nextStatus, errorMessage);
|
|
|
|
| 942 |
}
|
| 943 |
}
|
| 944 |
|
| 945 |
+
// A manual failure/cancellation can happen while Playwright is still
|
| 946 |
+
// unwinding. Preserve that terminal decision after account cleanup.
|
| 947 |
+
const latestJob = await getJobById(jobId).catch(() => null);
|
| 948 |
+
if (latestJob && ['failed', 'cancelled'].includes(latestJob.status)) {
|
| 949 |
+
jobLog.info('Job already terminal after worker failure; retry status not changed', {
|
| 950 |
+
status: latestJob.status,
|
| 951 |
+
});
|
| 952 |
+
return;
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
// Check if we should retry
|
| 956 |
if (shouldRetry) {
|
| 957 |
await updateJobStatus(jobId, 'pending', {
|
|
|
|
| 1168 |
return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
|
| 1169 |
}
|
| 1170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
/**
|
| 1172 |
* Helper to emit a job event.
|
| 1173 |
*/
|
|
|
|
| 1252 |
/PDF downloaded successfully/i.test(event.message)
|
| 1253 |
) {
|
| 1254 |
fields.last_completed_step = 'download';
|
| 1255 |
+
} else if (
|
| 1256 |
+
event.step === 'receipt' &&
|
| 1257 |
+
event.level === 'info' &&
|
| 1258 |
+
/Digital Receipt downloaded successfully/i.test(event.message)
|
| 1259 |
+
) {
|
| 1260 |
+
fields.last_completed_step = 'receipt';
|
| 1261 |
}
|
| 1262 |
|
| 1263 |
if (Object.keys(fields).length > 0) {
|