RelVDev2 commited on
Commit
4aaad6a
·
1 Parent(s): 4f843e7

Deploy legacy receipt and recovery update

Browse files
src/cron/cleanup-reports.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { config } from '../config';
2
  import { logger } from '../utils/logger';
3
- import { getExpiredReportJobs, updateJobStatus } from '../db/jobs';
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 PDFs from Supabase Storage after output_pdf_expires_at has passed.
12
- * The storage path is preserved in the DB for audit trail.
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
- try {
33
- if (job.output_pdf_path) {
 
 
 
 
 
 
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,quota_limited)')
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
- .not('output_pdf_path', 'is', null)
138
- .lte('output_pdf_expires_at', new Date().toISOString());
 
 
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
- jobId: string,
14
  fileName: string,
15
  fileBuffer: Buffer,
 
16
  ): Promise<string> {
17
  const ext = path.extname(fileName);
18
- const storagePath = `${userId}/${jobId}/input${ext}`;
19
 
20
  const { error } = await supabase.storage
21
  .from(config.inputBucket)
22
  .upload(storagePath, fileBuffer, {
23
  contentType: getMimeType(ext),
24
- upsert: false,
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<string> {
31
- const { data, error } = await supabase.rpc('create_job_with_ticket', {
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 jobId = data as string;
46
-
47
- if (params.inputFileSize != null || params.inputFileSha256) {
48
- const { error: updateError } = await supabase
49
- .from('turnitin_jobs')
50
- .update({
51
- input_file_size: params.inputFileSize ?? null,
52
- input_file_sha256: params.inputFileSha256 ?? null,
53
- updated_at: new Date().toISOString(),
54
- })
55
- .eq('id', jobId);
56
-
57
- if (updateError) {
58
- logger.warn('Created job but could not persist input file fingerprint', {
59
- jobId,
60
- error: updateError.message,
61
- });
62
- }
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
- return jobId;
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
- '[data-px="EVSimReportDownloadCurrentView"]',
1615
- '[aria-label="Current View"]',
1616
- '.print-download-items [role="button"]:has-text("Current View")',
1617
- '.print-download-btn:has-text("Current View")',
1618
- 'button:has-text("Current View")',
1619
- 'a:has-text("Current View")',
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
- throw new Error('Legacy Current View download option was not found.');
 
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('Legacy PDF download failed.');
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(page, context, outputPdfPath);
 
 
 
 
 
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 { createJobWithTicket } from '../../db/tickets';
6
- import { getUserProfile } from '../../db/tickets';
 
 
 
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 { assignment_target_id, mode, filters: filtersRaw } = authReq.body;
 
 
 
 
 
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. Upload file to Supabase Storage
129
  const inputFileSha256 = createHash('sha256')
130
  .update(authReq.file.buffer)
131
  .digest('hex');
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  const storagePath = await uploadInputFile(
134
  authReq.userId,
135
- // Generate a temp ID for the path — will be replaced with actual job ID
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. Create job with ticket deduction
143
- const jobId = await createJobWithTicket({
 
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({ jobId, ticketBalance });
 
 
 
 
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
- const account = shouldClaimPreviousAccount
 
 
 
 
 
356
  ? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId)
357
  : await claimAccountForJob(job, identityId, jobLog, accountPoolKey);
358
 
359
  if (!account) {
360
- if (shouldClaimPreviousAccount) {
361
- const message = resumeNeedsSameAccount
362
- ? 'Previous submission is still locked by its Turnitin account; retrying shortly.'
363
- : 'Previous Turnitin account is not ready yet; retrying with the same account before using a new one.';
 
 
 
 
 
 
 
 
 
 
 
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
- // Update job to completed
582
- await updateJobStatus(jobId, 'completed', {
 
 
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
- await emitEvent(jobId, identityId, 'info', 'completed', `Job completed. Similarity: ${result.similarityPercent ?? 'N/A'}%`);
703
- jobLog.info('Job completed successfully', {
704
- similarity: result.similarityPercent,
705
- viewerUrl: result.viewerUrl,
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 (submissionConsumedThisAttempt && remainingAfterConsumedSubmit === 0) {
 
 
821
  nextStatus = 'quota_limited';
822
- } else if (submissionConsumedThisAttempt && remainingAfterConsumedSubmit === null) {
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
- remainingAfterConsumedSubmit === null
 
 
833
  ? 'Quota needs refresh after a failed post-submit attempt.'
834
  : null,
835
  turnitin_next_retry_at:
836
- nextStatus === 'cooling_down' || nextStatus === 'quota_limited'
 
 
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
- 'Reserved for retry after submitted file reached report viewer.',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {