relv-dev commited on
Commit
2db5489
·
verified ·
1 Parent(s): 629f3e4

Deploy latest verified Turnitin worker

Browse files
Files changed (48) hide show
  1. .env.example +39 -0
  2. .gitignore +5 -0
  3. Dockerfile +42 -0
  4. README.md +7 -5
  5. migrate-last-completed-step.js +57 -0
  6. package-lock.json +1845 -0
  7. package.json +31 -0
  8. src/config.ts +76 -0
  9. src/cron/cleanup-reports.ts +99 -0
  10. src/cron/quota-check.ts +251 -0
  11. src/cron/stale-recovery.ts +302 -0
  12. src/crypto/password.ts +73 -0
  13. src/db/accounts.ts +700 -0
  14. src/db/client.ts +6 -0
  15. src/db/events.ts +65 -0
  16. src/db/jobs.ts +257 -0
  17. src/db/storage.ts +222 -0
  18. src/db/tickets.ts +177 -0
  19. src/engine/legacy.ts +1917 -0
  20. src/engine/selectors.ts +502 -0
  21. src/engine/steps/class-management.ts +111 -0
  22. src/engine/steps/download.ts +190 -0
  23. src/engine/steps/filters.ts +780 -0
  24. src/engine/steps/login.ts +194 -0
  25. src/engine/steps/navigate.ts +90 -0
  26. src/engine/steps/quota-detect.ts +542 -0
  27. src/engine/steps/resubmit.ts +371 -0
  28. src/engine/steps/similarity.ts +267 -0
  29. src/engine/steps/submission-details.ts +147 -0
  30. src/engine/steps/submission-state.ts +263 -0
  31. src/engine/steps/upload.ts +931 -0
  32. src/engine/steps/viewer-similarity.ts +143 -0
  33. src/engine/steps/viewer.ts +139 -0
  34. src/engine/turnitin.ts +1036 -0
  35. src/index.ts +137 -0
  36. src/server/app.ts +67 -0
  37. src/server/middleware/admin-secret.ts +33 -0
  38. src/server/middleware/auth.ts +50 -0
  39. src/server/routes/accounts.ts +140 -0
  40. src/server/routes/health.ts +38 -0
  41. src/server/routes/internal.ts +145 -0
  42. src/server/routes/reports.ts +160 -0
  43. src/server/routes/submit.ts +275 -0
  44. src/utils/logger.ts +57 -0
  45. src/utils/retry.ts +62 -0
  46. src/worker/browser-pool.ts +107 -0
  47. src/worker/manager.ts +1313 -0
  48. tsconfig.json +20 -0
.env.example ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase
2
+ SUPABASE_URL=
3
+ SUPABASE_SERVICE_ROLE_KEY=
4
+
5
+ # Worker
6
+ WORKER_ID=hf-worker-1
7
+ MAX_WORKERS=2
8
+ POLL_INTERVAL_MS=3000
9
+ ENABLE_WORKER=true
10
+ ENABLE_CRON=true
11
+
12
+ # Turnitin
13
+ TURNITIN_SHARED_PASSWORD=
14
+ TURNITIN_CREDENTIAL_ENCRYPTION_KEY=
15
+ TARGET_URL=https://www.turnitin.com/login_page.asp?lang=en_us
16
+ CLASS_TITLE=Summer Reading 2026
17
+ ASSIGNMENT_TITLE=HTLLP notes
18
+
19
+ # Playwright
20
+ PLAYWRIGHT_HEADLESS=true
21
+
22
+ # Storage buckets
23
+ INPUT_BUCKET=turnitin-inputs
24
+ REPORT_BUCKET=turnitin-reports
25
+ DIAGNOSTICS_BUCKET=turnitin-diagnostics
26
+ SESSION_BUCKET=turnitin-sessions
27
+
28
+ # Cron intervals (minutes)
29
+ QUOTA_CHECK_INTERVAL=30
30
+ CLEANUP_INTERVAL=15
31
+ STALE_RECOVERY_INTERVAL=5
32
+
33
+ # Report
34
+ REPORT_RETENTION_HOURS=24
35
+
36
+ # Server
37
+ PORT=7860
38
+ INTERNAL_SECRET=
39
+ ALLOWED_ORIGINS=https://hl-internal-finance-amber.vercel.app,http://localhost:3000
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.log
5
+ tmp/
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-slim
2
+
3
+ # Install Chromium system dependencies
4
+ RUN apt-get update && apt-get install -y \
5
+ chromium \
6
+ fonts-liberation \
7
+ libgbm1 \
8
+ libnss3 \
9
+ libatk1.0-0 \
10
+ libatk-bridge2.0-0 \
11
+ libcups2 \
12
+ libxdamage1 \
13
+ libxrandr2 \
14
+ libpango-1.0-0 \
15
+ libcairo2 \
16
+ libasound2 \
17
+ --no-install-recommends \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ WORKDIR /app
21
+
22
+ # Copy package configurations
23
+ COPY package*.json tsconfig.json ./
24
+ RUN npm ci
25
+
26
+ # Copy source and build
27
+ COPY src/ ./src/
28
+ RUN npm run build
29
+
30
+ # Prune dev dependencies to reduce image size
31
+ RUN npm prune --production
32
+
33
+ # Install Playwright Chromium browser
34
+ RUN npx playwright install chromium
35
+
36
+ ENV NODE_ENV=production
37
+ ENV PLAYWRIGHT_HEADLESS=true
38
+ ENV PORT=7860
39
+
40
+ EXPOSE 7860
41
+
42
+ CMD ["node", "dist/index.js"]
README.md CHANGED
@@ -1,10 +1,12 @@
1
  ---
2
- title: Relv Space5
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: RelV Turnitin Worker
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  pinned: false
6
  ---
7
 
8
+ # RelV Turnitin Worker
9
+
10
+ Docker Space for the Turnitin automation worker.
11
+
12
+ Build revision: 2026-07-17T03:18:00Z
migrate-last-completed-step.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Migration: add last_completed_step column to turnitin_jobs
4
+ * Run: node migrate-last-completed-step.js
5
+ */
6
+
7
+ const { createClient } = require('@supabase/supabase-js');
8
+
9
+ const SUPABASE_URL = 'https://zbvlvxpnmsccdmoihnpo.supabase.co';
10
+ const SUPABASE_SERVICE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpidmx2eHBubXNjY2Rtb2lobnBvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjkxMjAwNSwiZXhwIjoyMDk4NDg4MDA1fQ.i6SfiVKB0YX4pi88OhQZyX8uCMREQcaOwdsB9CmGI1Q';
11
+
12
+ async function main() {
13
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
14
+
15
+ console.log('Running migration: add last_completed_step to turnitin_jobs...');
16
+
17
+ // Supabase JS client doesn't support raw DDL. Use the pg extension workaround
18
+ // by checking if the column already exists first.
19
+ const { data: columns, error: checkError } = await supabase
20
+ .from('information_schema.columns')
21
+ .select('column_name')
22
+ .eq('table_schema', 'public')
23
+ .eq('table_name', 'turnitin_jobs')
24
+ .eq('column_name', 'last_completed_step');
25
+
26
+ if (checkError) {
27
+ console.log('Could not check column existence via information_schema (RLS?):', checkError.message);
28
+ }
29
+
30
+ if (columns && columns.length > 0) {
31
+ console.log('✅ Column last_completed_step already exists — no migration needed.');
32
+ return;
33
+ }
34
+
35
+ // Try applying via the run_sql RPC if it exists
36
+ const { error } = await supabase.rpc('run_sql', {
37
+ sql: 'ALTER TABLE public.turnitin_jobs ADD COLUMN IF NOT EXISTS last_completed_step text;'
38
+ });
39
+
40
+ if (error) {
41
+ console.log('❌ run_sql RPC not available:', error.message);
42
+ console.log('');
43
+ console.log('Please run this SQL manually in the Supabase Dashboard SQL editor:');
44
+ console.log('');
45
+ console.log(' ALTER TABLE public.turnitin_jobs ADD COLUMN IF NOT EXISTS last_completed_step text;');
46
+ console.log('');
47
+ console.log('URL: https://supabase.com/dashboard/project/zbvlvxpnmsccdmoihnpo/sql/new');
48
+ process.exit(1);
49
+ }
50
+
51
+ console.log('✅ Migration applied successfully.');
52
+ }
53
+
54
+ main().catch((e) => {
55
+ console.error('Fatal error:', e);
56
+ process.exit(1);
57
+ });
package-lock.json ADDED
@@ -0,0 +1,1845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "turnitin-worker",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "turnitin-worker",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "@supabase/supabase-js": "^2.49.0",
12
+ "dotenv": "^16.4.5",
13
+ "express": "^4.21.0",
14
+ "multer": "^1.4.5-lts.1",
15
+ "node-cron": "^3.0.3",
16
+ "playwright": "^1.49.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/express": "^5.0.0",
20
+ "@types/multer": "^1.4.12",
21
+ "@types/node": "^22.0.0",
22
+ "@types/node-cron": "^3.0.11",
23
+ "tsx": "^4.19.0",
24
+ "typescript": "^5.7.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ }
29
+ },
30
+ "node_modules/@esbuild/aix-ppc64": {
31
+ "version": "0.28.1",
32
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
33
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
34
+ "cpu": [
35
+ "ppc64"
36
+ ],
37
+ "dev": true,
38
+ "license": "MIT",
39
+ "optional": true,
40
+ "os": [
41
+ "aix"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ },
47
+ "node_modules/@esbuild/android-arm": {
48
+ "version": "0.28.1",
49
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
50
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
51
+ "cpu": [
52
+ "arm"
53
+ ],
54
+ "dev": true,
55
+ "license": "MIT",
56
+ "optional": true,
57
+ "os": [
58
+ "android"
59
+ ],
60
+ "engines": {
61
+ "node": ">=18"
62
+ }
63
+ },
64
+ "node_modules/@esbuild/android-arm64": {
65
+ "version": "0.28.1",
66
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
67
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
68
+ "cpu": [
69
+ "arm64"
70
+ ],
71
+ "dev": true,
72
+ "license": "MIT",
73
+ "optional": true,
74
+ "os": [
75
+ "android"
76
+ ],
77
+ "engines": {
78
+ "node": ">=18"
79
+ }
80
+ },
81
+ "node_modules/@esbuild/android-x64": {
82
+ "version": "0.28.1",
83
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
84
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
85
+ "cpu": [
86
+ "x64"
87
+ ],
88
+ "dev": true,
89
+ "license": "MIT",
90
+ "optional": true,
91
+ "os": [
92
+ "android"
93
+ ],
94
+ "engines": {
95
+ "node": ">=18"
96
+ }
97
+ },
98
+ "node_modules/@esbuild/darwin-arm64": {
99
+ "version": "0.28.1",
100
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
101
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
102
+ "cpu": [
103
+ "arm64"
104
+ ],
105
+ "dev": true,
106
+ "license": "MIT",
107
+ "optional": true,
108
+ "os": [
109
+ "darwin"
110
+ ],
111
+ "engines": {
112
+ "node": ">=18"
113
+ }
114
+ },
115
+ "node_modules/@esbuild/darwin-x64": {
116
+ "version": "0.28.1",
117
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
118
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
119
+ "cpu": [
120
+ "x64"
121
+ ],
122
+ "dev": true,
123
+ "license": "MIT",
124
+ "optional": true,
125
+ "os": [
126
+ "darwin"
127
+ ],
128
+ "engines": {
129
+ "node": ">=18"
130
+ }
131
+ },
132
+ "node_modules/@esbuild/freebsd-arm64": {
133
+ "version": "0.28.1",
134
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
135
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
136
+ "cpu": [
137
+ "arm64"
138
+ ],
139
+ "dev": true,
140
+ "license": "MIT",
141
+ "optional": true,
142
+ "os": [
143
+ "freebsd"
144
+ ],
145
+ "engines": {
146
+ "node": ">=18"
147
+ }
148
+ },
149
+ "node_modules/@esbuild/freebsd-x64": {
150
+ "version": "0.28.1",
151
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
152
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
153
+ "cpu": [
154
+ "x64"
155
+ ],
156
+ "dev": true,
157
+ "license": "MIT",
158
+ "optional": true,
159
+ "os": [
160
+ "freebsd"
161
+ ],
162
+ "engines": {
163
+ "node": ">=18"
164
+ }
165
+ },
166
+ "node_modules/@esbuild/linux-arm": {
167
+ "version": "0.28.1",
168
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
169
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
170
+ "cpu": [
171
+ "arm"
172
+ ],
173
+ "dev": true,
174
+ "license": "MIT",
175
+ "optional": true,
176
+ "os": [
177
+ "linux"
178
+ ],
179
+ "engines": {
180
+ "node": ">=18"
181
+ }
182
+ },
183
+ "node_modules/@esbuild/linux-arm64": {
184
+ "version": "0.28.1",
185
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
186
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
187
+ "cpu": [
188
+ "arm64"
189
+ ],
190
+ "dev": true,
191
+ "license": "MIT",
192
+ "optional": true,
193
+ "os": [
194
+ "linux"
195
+ ],
196
+ "engines": {
197
+ "node": ">=18"
198
+ }
199
+ },
200
+ "node_modules/@esbuild/linux-ia32": {
201
+ "version": "0.28.1",
202
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
203
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
204
+ "cpu": [
205
+ "ia32"
206
+ ],
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "optional": true,
210
+ "os": [
211
+ "linux"
212
+ ],
213
+ "engines": {
214
+ "node": ">=18"
215
+ }
216
+ },
217
+ "node_modules/@esbuild/linux-loong64": {
218
+ "version": "0.28.1",
219
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
220
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
221
+ "cpu": [
222
+ "loong64"
223
+ ],
224
+ "dev": true,
225
+ "license": "MIT",
226
+ "optional": true,
227
+ "os": [
228
+ "linux"
229
+ ],
230
+ "engines": {
231
+ "node": ">=18"
232
+ }
233
+ },
234
+ "node_modules/@esbuild/linux-mips64el": {
235
+ "version": "0.28.1",
236
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
237
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
238
+ "cpu": [
239
+ "mips64el"
240
+ ],
241
+ "dev": true,
242
+ "license": "MIT",
243
+ "optional": true,
244
+ "os": [
245
+ "linux"
246
+ ],
247
+ "engines": {
248
+ "node": ">=18"
249
+ }
250
+ },
251
+ "node_modules/@esbuild/linux-ppc64": {
252
+ "version": "0.28.1",
253
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
254
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
255
+ "cpu": [
256
+ "ppc64"
257
+ ],
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "optional": true,
261
+ "os": [
262
+ "linux"
263
+ ],
264
+ "engines": {
265
+ "node": ">=18"
266
+ }
267
+ },
268
+ "node_modules/@esbuild/linux-riscv64": {
269
+ "version": "0.28.1",
270
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
271
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
272
+ "cpu": [
273
+ "riscv64"
274
+ ],
275
+ "dev": true,
276
+ "license": "MIT",
277
+ "optional": true,
278
+ "os": [
279
+ "linux"
280
+ ],
281
+ "engines": {
282
+ "node": ">=18"
283
+ }
284
+ },
285
+ "node_modules/@esbuild/linux-s390x": {
286
+ "version": "0.28.1",
287
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
288
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
289
+ "cpu": [
290
+ "s390x"
291
+ ],
292
+ "dev": true,
293
+ "license": "MIT",
294
+ "optional": true,
295
+ "os": [
296
+ "linux"
297
+ ],
298
+ "engines": {
299
+ "node": ">=18"
300
+ }
301
+ },
302
+ "node_modules/@esbuild/linux-x64": {
303
+ "version": "0.28.1",
304
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
305
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
306
+ "cpu": [
307
+ "x64"
308
+ ],
309
+ "dev": true,
310
+ "license": "MIT",
311
+ "optional": true,
312
+ "os": [
313
+ "linux"
314
+ ],
315
+ "engines": {
316
+ "node": ">=18"
317
+ }
318
+ },
319
+ "node_modules/@esbuild/netbsd-arm64": {
320
+ "version": "0.28.1",
321
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
322
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
323
+ "cpu": [
324
+ "arm64"
325
+ ],
326
+ "dev": true,
327
+ "license": "MIT",
328
+ "optional": true,
329
+ "os": [
330
+ "netbsd"
331
+ ],
332
+ "engines": {
333
+ "node": ">=18"
334
+ }
335
+ },
336
+ "node_modules/@esbuild/netbsd-x64": {
337
+ "version": "0.28.1",
338
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
339
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
340
+ "cpu": [
341
+ "x64"
342
+ ],
343
+ "dev": true,
344
+ "license": "MIT",
345
+ "optional": true,
346
+ "os": [
347
+ "netbsd"
348
+ ],
349
+ "engines": {
350
+ "node": ">=18"
351
+ }
352
+ },
353
+ "node_modules/@esbuild/openbsd-arm64": {
354
+ "version": "0.28.1",
355
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
356
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
357
+ "cpu": [
358
+ "arm64"
359
+ ],
360
+ "dev": true,
361
+ "license": "MIT",
362
+ "optional": true,
363
+ "os": [
364
+ "openbsd"
365
+ ],
366
+ "engines": {
367
+ "node": ">=18"
368
+ }
369
+ },
370
+ "node_modules/@esbuild/openbsd-x64": {
371
+ "version": "0.28.1",
372
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
373
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
374
+ "cpu": [
375
+ "x64"
376
+ ],
377
+ "dev": true,
378
+ "license": "MIT",
379
+ "optional": true,
380
+ "os": [
381
+ "openbsd"
382
+ ],
383
+ "engines": {
384
+ "node": ">=18"
385
+ }
386
+ },
387
+ "node_modules/@esbuild/openharmony-arm64": {
388
+ "version": "0.28.1",
389
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
390
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
391
+ "cpu": [
392
+ "arm64"
393
+ ],
394
+ "dev": true,
395
+ "license": "MIT",
396
+ "optional": true,
397
+ "os": [
398
+ "openharmony"
399
+ ],
400
+ "engines": {
401
+ "node": ">=18"
402
+ }
403
+ },
404
+ "node_modules/@esbuild/sunos-x64": {
405
+ "version": "0.28.1",
406
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
407
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
408
+ "cpu": [
409
+ "x64"
410
+ ],
411
+ "dev": true,
412
+ "license": "MIT",
413
+ "optional": true,
414
+ "os": [
415
+ "sunos"
416
+ ],
417
+ "engines": {
418
+ "node": ">=18"
419
+ }
420
+ },
421
+ "node_modules/@esbuild/win32-arm64": {
422
+ "version": "0.28.1",
423
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
424
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
425
+ "cpu": [
426
+ "arm64"
427
+ ],
428
+ "dev": true,
429
+ "license": "MIT",
430
+ "optional": true,
431
+ "os": [
432
+ "win32"
433
+ ],
434
+ "engines": {
435
+ "node": ">=18"
436
+ }
437
+ },
438
+ "node_modules/@esbuild/win32-ia32": {
439
+ "version": "0.28.1",
440
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
441
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
442
+ "cpu": [
443
+ "ia32"
444
+ ],
445
+ "dev": true,
446
+ "license": "MIT",
447
+ "optional": true,
448
+ "os": [
449
+ "win32"
450
+ ],
451
+ "engines": {
452
+ "node": ">=18"
453
+ }
454
+ },
455
+ "node_modules/@esbuild/win32-x64": {
456
+ "version": "0.28.1",
457
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
458
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
459
+ "cpu": [
460
+ "x64"
461
+ ],
462
+ "dev": true,
463
+ "license": "MIT",
464
+ "optional": true,
465
+ "os": [
466
+ "win32"
467
+ ],
468
+ "engines": {
469
+ "node": ">=18"
470
+ }
471
+ },
472
+ "node_modules/@supabase/auth-js": {
473
+ "version": "2.110.0",
474
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.0.tgz",
475
+ "integrity": "sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==",
476
+ "license": "MIT",
477
+ "dependencies": {
478
+ "tslib": "2.8.1"
479
+ },
480
+ "engines": {
481
+ "node": ">=22.0.0"
482
+ }
483
+ },
484
+ "node_modules/@supabase/functions-js": {
485
+ "version": "2.110.0",
486
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.0.tgz",
487
+ "integrity": "sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==",
488
+ "license": "MIT",
489
+ "dependencies": {
490
+ "tslib": "2.8.1"
491
+ },
492
+ "engines": {
493
+ "node": ">=22.0.0"
494
+ }
495
+ },
496
+ "node_modules/@supabase/phoenix": {
497
+ "version": "0.4.4",
498
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
499
+ "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
500
+ "license": "MIT"
501
+ },
502
+ "node_modules/@supabase/postgrest-js": {
503
+ "version": "2.110.0",
504
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.0.tgz",
505
+ "integrity": "sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==",
506
+ "license": "MIT",
507
+ "dependencies": {
508
+ "tslib": "2.8.1"
509
+ },
510
+ "engines": {
511
+ "node": ">=22.0.0"
512
+ }
513
+ },
514
+ "node_modules/@supabase/realtime-js": {
515
+ "version": "2.110.0",
516
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.0.tgz",
517
+ "integrity": "sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==",
518
+ "license": "MIT",
519
+ "dependencies": {
520
+ "@supabase/phoenix": "0.4.4",
521
+ "tslib": "2.8.1"
522
+ },
523
+ "engines": {
524
+ "node": ">=22.0.0"
525
+ }
526
+ },
527
+ "node_modules/@supabase/storage-js": {
528
+ "version": "2.110.0",
529
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.0.tgz",
530
+ "integrity": "sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==",
531
+ "license": "MIT",
532
+ "dependencies": {
533
+ "iceberg-js": "^0.8.1",
534
+ "tslib": "2.8.1"
535
+ },
536
+ "engines": {
537
+ "node": ">=22.0.0"
538
+ }
539
+ },
540
+ "node_modules/@supabase/supabase-js": {
541
+ "version": "2.110.0",
542
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.0.tgz",
543
+ "integrity": "sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==",
544
+ "license": "MIT",
545
+ "dependencies": {
546
+ "@supabase/auth-js": "2.110.0",
547
+ "@supabase/functions-js": "2.110.0",
548
+ "@supabase/postgrest-js": "2.110.0",
549
+ "@supabase/realtime-js": "2.110.0",
550
+ "@supabase/storage-js": "2.110.0"
551
+ },
552
+ "engines": {
553
+ "node": ">=22.0.0"
554
+ }
555
+ },
556
+ "node_modules/@types/body-parser": {
557
+ "version": "1.19.6",
558
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
559
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
560
+ "dev": true,
561
+ "license": "MIT",
562
+ "dependencies": {
563
+ "@types/connect": "*",
564
+ "@types/node": "*"
565
+ }
566
+ },
567
+ "node_modules/@types/connect": {
568
+ "version": "3.4.38",
569
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
570
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
571
+ "dev": true,
572
+ "license": "MIT",
573
+ "dependencies": {
574
+ "@types/node": "*"
575
+ }
576
+ },
577
+ "node_modules/@types/express": {
578
+ "version": "5.0.6",
579
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
580
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
581
+ "dev": true,
582
+ "license": "MIT",
583
+ "dependencies": {
584
+ "@types/body-parser": "*",
585
+ "@types/express-serve-static-core": "^5.0.0",
586
+ "@types/serve-static": "^2"
587
+ }
588
+ },
589
+ "node_modules/@types/express-serve-static-core": {
590
+ "version": "5.1.1",
591
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
592
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
593
+ "dev": true,
594
+ "license": "MIT",
595
+ "dependencies": {
596
+ "@types/node": "*",
597
+ "@types/qs": "*",
598
+ "@types/range-parser": "*",
599
+ "@types/send": "*"
600
+ }
601
+ },
602
+ "node_modules/@types/http-errors": {
603
+ "version": "2.0.5",
604
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
605
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
606
+ "dev": true,
607
+ "license": "MIT"
608
+ },
609
+ "node_modules/@types/multer": {
610
+ "version": "1.4.13",
611
+ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
612
+ "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
613
+ "dev": true,
614
+ "license": "MIT",
615
+ "dependencies": {
616
+ "@types/express": "*"
617
+ }
618
+ },
619
+ "node_modules/@types/node": {
620
+ "version": "22.20.0",
621
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
622
+ "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
623
+ "dev": true,
624
+ "license": "MIT",
625
+ "dependencies": {
626
+ "undici-types": "~6.21.0"
627
+ }
628
+ },
629
+ "node_modules/@types/node-cron": {
630
+ "version": "3.0.11",
631
+ "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
632
+ "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
633
+ "dev": true,
634
+ "license": "MIT"
635
+ },
636
+ "node_modules/@types/qs": {
637
+ "version": "6.15.1",
638
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
639
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
640
+ "dev": true,
641
+ "license": "MIT"
642
+ },
643
+ "node_modules/@types/range-parser": {
644
+ "version": "1.2.7",
645
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
646
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
647
+ "dev": true,
648
+ "license": "MIT"
649
+ },
650
+ "node_modules/@types/send": {
651
+ "version": "1.2.1",
652
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
653
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
654
+ "dev": true,
655
+ "license": "MIT",
656
+ "dependencies": {
657
+ "@types/node": "*"
658
+ }
659
+ },
660
+ "node_modules/@types/serve-static": {
661
+ "version": "2.2.0",
662
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
663
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
664
+ "dev": true,
665
+ "license": "MIT",
666
+ "dependencies": {
667
+ "@types/http-errors": "*",
668
+ "@types/node": "*"
669
+ }
670
+ },
671
+ "node_modules/accepts": {
672
+ "version": "1.3.8",
673
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
674
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
675
+ "license": "MIT",
676
+ "dependencies": {
677
+ "mime-types": "~2.1.34",
678
+ "negotiator": "0.6.3"
679
+ },
680
+ "engines": {
681
+ "node": ">= 0.6"
682
+ }
683
+ },
684
+ "node_modules/append-field": {
685
+ "version": "1.0.0",
686
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
687
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
688
+ "license": "MIT"
689
+ },
690
+ "node_modules/array-flatten": {
691
+ "version": "1.1.1",
692
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
693
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
694
+ "license": "MIT"
695
+ },
696
+ "node_modules/body-parser": {
697
+ "version": "1.20.5",
698
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
699
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
700
+ "license": "MIT",
701
+ "dependencies": {
702
+ "bytes": "~3.1.2",
703
+ "content-type": "~1.0.5",
704
+ "debug": "2.6.9",
705
+ "depd": "2.0.0",
706
+ "destroy": "~1.2.0",
707
+ "http-errors": "~2.0.1",
708
+ "iconv-lite": "~0.4.24",
709
+ "on-finished": "~2.4.1",
710
+ "qs": "~6.15.1",
711
+ "raw-body": "~2.5.3",
712
+ "type-is": "~1.6.18",
713
+ "unpipe": "~1.0.0"
714
+ },
715
+ "engines": {
716
+ "node": ">= 0.8",
717
+ "npm": "1.2.8000 || >= 1.4.16"
718
+ }
719
+ },
720
+ "node_modules/buffer-from": {
721
+ "version": "1.1.2",
722
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
723
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
724
+ "license": "MIT"
725
+ },
726
+ "node_modules/busboy": {
727
+ "version": "1.6.0",
728
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
729
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
730
+ "dependencies": {
731
+ "streamsearch": "^1.1.0"
732
+ },
733
+ "engines": {
734
+ "node": ">=10.16.0"
735
+ }
736
+ },
737
+ "node_modules/bytes": {
738
+ "version": "3.1.2",
739
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
740
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
741
+ "license": "MIT",
742
+ "engines": {
743
+ "node": ">= 0.8"
744
+ }
745
+ },
746
+ "node_modules/call-bind-apply-helpers": {
747
+ "version": "1.0.2",
748
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
749
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
750
+ "license": "MIT",
751
+ "dependencies": {
752
+ "es-errors": "^1.3.0",
753
+ "function-bind": "^1.1.2"
754
+ },
755
+ "engines": {
756
+ "node": ">= 0.4"
757
+ }
758
+ },
759
+ "node_modules/call-bound": {
760
+ "version": "1.0.4",
761
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
762
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
763
+ "license": "MIT",
764
+ "dependencies": {
765
+ "call-bind-apply-helpers": "^1.0.2",
766
+ "get-intrinsic": "^1.3.0"
767
+ },
768
+ "engines": {
769
+ "node": ">= 0.4"
770
+ },
771
+ "funding": {
772
+ "url": "https://github.com/sponsors/ljharb"
773
+ }
774
+ },
775
+ "node_modules/concat-stream": {
776
+ "version": "1.6.2",
777
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
778
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
779
+ "engines": [
780
+ "node >= 0.8"
781
+ ],
782
+ "license": "MIT",
783
+ "dependencies": {
784
+ "buffer-from": "^1.0.0",
785
+ "inherits": "^2.0.3",
786
+ "readable-stream": "^2.2.2",
787
+ "typedarray": "^0.0.6"
788
+ }
789
+ },
790
+ "node_modules/content-disposition": {
791
+ "version": "0.5.4",
792
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
793
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
794
+ "license": "MIT",
795
+ "dependencies": {
796
+ "safe-buffer": "5.2.1"
797
+ },
798
+ "engines": {
799
+ "node": ">= 0.6"
800
+ }
801
+ },
802
+ "node_modules/content-type": {
803
+ "version": "1.0.5",
804
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
805
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
806
+ "license": "MIT",
807
+ "engines": {
808
+ "node": ">= 0.6"
809
+ }
810
+ },
811
+ "node_modules/cookie": {
812
+ "version": "0.7.2",
813
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
814
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
815
+ "license": "MIT",
816
+ "engines": {
817
+ "node": ">= 0.6"
818
+ }
819
+ },
820
+ "node_modules/cookie-signature": {
821
+ "version": "1.0.7",
822
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
823
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
824
+ "license": "MIT"
825
+ },
826
+ "node_modules/core-util-is": {
827
+ "version": "1.0.3",
828
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
829
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
830
+ "license": "MIT"
831
+ },
832
+ "node_modules/debug": {
833
+ "version": "2.6.9",
834
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
835
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
836
+ "license": "MIT",
837
+ "dependencies": {
838
+ "ms": "2.0.0"
839
+ }
840
+ },
841
+ "node_modules/depd": {
842
+ "version": "2.0.0",
843
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
844
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
845
+ "license": "MIT",
846
+ "engines": {
847
+ "node": ">= 0.8"
848
+ }
849
+ },
850
+ "node_modules/destroy": {
851
+ "version": "1.2.0",
852
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
853
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
854
+ "license": "MIT",
855
+ "engines": {
856
+ "node": ">= 0.8",
857
+ "npm": "1.2.8000 || >= 1.4.16"
858
+ }
859
+ },
860
+ "node_modules/dotenv": {
861
+ "version": "16.6.1",
862
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
863
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
864
+ "license": "BSD-2-Clause",
865
+ "engines": {
866
+ "node": ">=12"
867
+ },
868
+ "funding": {
869
+ "url": "https://dotenvx.com"
870
+ }
871
+ },
872
+ "node_modules/dunder-proto": {
873
+ "version": "1.0.1",
874
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
875
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
876
+ "license": "MIT",
877
+ "dependencies": {
878
+ "call-bind-apply-helpers": "^1.0.1",
879
+ "es-errors": "^1.3.0",
880
+ "gopd": "^1.2.0"
881
+ },
882
+ "engines": {
883
+ "node": ">= 0.4"
884
+ }
885
+ },
886
+ "node_modules/ee-first": {
887
+ "version": "1.1.1",
888
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
889
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
890
+ "license": "MIT"
891
+ },
892
+ "node_modules/encodeurl": {
893
+ "version": "2.0.0",
894
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
895
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
896
+ "license": "MIT",
897
+ "engines": {
898
+ "node": ">= 0.8"
899
+ }
900
+ },
901
+ "node_modules/es-define-property": {
902
+ "version": "1.0.1",
903
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
904
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
905
+ "license": "MIT",
906
+ "engines": {
907
+ "node": ">= 0.4"
908
+ }
909
+ },
910
+ "node_modules/es-errors": {
911
+ "version": "1.3.0",
912
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
913
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
914
+ "license": "MIT",
915
+ "engines": {
916
+ "node": ">= 0.4"
917
+ }
918
+ },
919
+ "node_modules/es-object-atoms": {
920
+ "version": "1.1.2",
921
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
922
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
923
+ "license": "MIT",
924
+ "dependencies": {
925
+ "es-errors": "^1.3.0"
926
+ },
927
+ "engines": {
928
+ "node": ">= 0.4"
929
+ }
930
+ },
931
+ "node_modules/esbuild": {
932
+ "version": "0.28.1",
933
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
934
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
935
+ "dev": true,
936
+ "hasInstallScript": true,
937
+ "license": "MIT",
938
+ "bin": {
939
+ "esbuild": "bin/esbuild"
940
+ },
941
+ "engines": {
942
+ "node": ">=18"
943
+ },
944
+ "optionalDependencies": {
945
+ "@esbuild/aix-ppc64": "0.28.1",
946
+ "@esbuild/android-arm": "0.28.1",
947
+ "@esbuild/android-arm64": "0.28.1",
948
+ "@esbuild/android-x64": "0.28.1",
949
+ "@esbuild/darwin-arm64": "0.28.1",
950
+ "@esbuild/darwin-x64": "0.28.1",
951
+ "@esbuild/freebsd-arm64": "0.28.1",
952
+ "@esbuild/freebsd-x64": "0.28.1",
953
+ "@esbuild/linux-arm": "0.28.1",
954
+ "@esbuild/linux-arm64": "0.28.1",
955
+ "@esbuild/linux-ia32": "0.28.1",
956
+ "@esbuild/linux-loong64": "0.28.1",
957
+ "@esbuild/linux-mips64el": "0.28.1",
958
+ "@esbuild/linux-ppc64": "0.28.1",
959
+ "@esbuild/linux-riscv64": "0.28.1",
960
+ "@esbuild/linux-s390x": "0.28.1",
961
+ "@esbuild/linux-x64": "0.28.1",
962
+ "@esbuild/netbsd-arm64": "0.28.1",
963
+ "@esbuild/netbsd-x64": "0.28.1",
964
+ "@esbuild/openbsd-arm64": "0.28.1",
965
+ "@esbuild/openbsd-x64": "0.28.1",
966
+ "@esbuild/openharmony-arm64": "0.28.1",
967
+ "@esbuild/sunos-x64": "0.28.1",
968
+ "@esbuild/win32-arm64": "0.28.1",
969
+ "@esbuild/win32-ia32": "0.28.1",
970
+ "@esbuild/win32-x64": "0.28.1"
971
+ }
972
+ },
973
+ "node_modules/escape-html": {
974
+ "version": "1.0.3",
975
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
976
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
977
+ "license": "MIT"
978
+ },
979
+ "node_modules/etag": {
980
+ "version": "1.8.1",
981
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
982
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
983
+ "license": "MIT",
984
+ "engines": {
985
+ "node": ">= 0.6"
986
+ }
987
+ },
988
+ "node_modules/express": {
989
+ "version": "4.22.2",
990
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
991
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
992
+ "license": "MIT",
993
+ "dependencies": {
994
+ "accepts": "~1.3.8",
995
+ "array-flatten": "1.1.1",
996
+ "body-parser": "~1.20.5",
997
+ "content-disposition": "~0.5.4",
998
+ "content-type": "~1.0.4",
999
+ "cookie": "~0.7.1",
1000
+ "cookie-signature": "~1.0.6",
1001
+ "debug": "2.6.9",
1002
+ "depd": "2.0.0",
1003
+ "encodeurl": "~2.0.0",
1004
+ "escape-html": "~1.0.3",
1005
+ "etag": "~1.8.1",
1006
+ "finalhandler": "~1.3.1",
1007
+ "fresh": "~0.5.2",
1008
+ "http-errors": "~2.0.0",
1009
+ "merge-descriptors": "1.0.3",
1010
+ "methods": "~1.1.2",
1011
+ "on-finished": "~2.4.1",
1012
+ "parseurl": "~1.3.3",
1013
+ "path-to-regexp": "~0.1.12",
1014
+ "proxy-addr": "~2.0.7",
1015
+ "qs": "~6.15.1",
1016
+ "range-parser": "~1.2.1",
1017
+ "safe-buffer": "5.2.1",
1018
+ "send": "~0.19.0",
1019
+ "serve-static": "~1.16.2",
1020
+ "setprototypeof": "1.2.0",
1021
+ "statuses": "~2.0.1",
1022
+ "type-is": "~1.6.18",
1023
+ "utils-merge": "1.0.1",
1024
+ "vary": "~1.1.2"
1025
+ },
1026
+ "engines": {
1027
+ "node": ">= 0.10.0"
1028
+ },
1029
+ "funding": {
1030
+ "type": "opencollective",
1031
+ "url": "https://opencollective.com/express"
1032
+ }
1033
+ },
1034
+ "node_modules/finalhandler": {
1035
+ "version": "1.3.2",
1036
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
1037
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
1038
+ "license": "MIT",
1039
+ "dependencies": {
1040
+ "debug": "2.6.9",
1041
+ "encodeurl": "~2.0.0",
1042
+ "escape-html": "~1.0.3",
1043
+ "on-finished": "~2.4.1",
1044
+ "parseurl": "~1.3.3",
1045
+ "statuses": "~2.0.2",
1046
+ "unpipe": "~1.0.0"
1047
+ },
1048
+ "engines": {
1049
+ "node": ">= 0.8"
1050
+ }
1051
+ },
1052
+ "node_modules/forwarded": {
1053
+ "version": "0.2.0",
1054
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
1055
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
1056
+ "license": "MIT",
1057
+ "engines": {
1058
+ "node": ">= 0.6"
1059
+ }
1060
+ },
1061
+ "node_modules/fresh": {
1062
+ "version": "0.5.2",
1063
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
1064
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
1065
+ "license": "MIT",
1066
+ "engines": {
1067
+ "node": ">= 0.6"
1068
+ }
1069
+ },
1070
+ "node_modules/fsevents": {
1071
+ "version": "2.3.2",
1072
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1073
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1074
+ "hasInstallScript": true,
1075
+ "license": "MIT",
1076
+ "optional": true,
1077
+ "os": [
1078
+ "darwin"
1079
+ ],
1080
+ "engines": {
1081
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1082
+ }
1083
+ },
1084
+ "node_modules/function-bind": {
1085
+ "version": "1.1.2",
1086
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1087
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1088
+ "license": "MIT",
1089
+ "funding": {
1090
+ "url": "https://github.com/sponsors/ljharb"
1091
+ }
1092
+ },
1093
+ "node_modules/get-intrinsic": {
1094
+ "version": "1.3.0",
1095
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
1096
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1097
+ "license": "MIT",
1098
+ "dependencies": {
1099
+ "call-bind-apply-helpers": "^1.0.2",
1100
+ "es-define-property": "^1.0.1",
1101
+ "es-errors": "^1.3.0",
1102
+ "es-object-atoms": "^1.1.1",
1103
+ "function-bind": "^1.1.2",
1104
+ "get-proto": "^1.0.1",
1105
+ "gopd": "^1.2.0",
1106
+ "has-symbols": "^1.1.0",
1107
+ "hasown": "^2.0.2",
1108
+ "math-intrinsics": "^1.1.0"
1109
+ },
1110
+ "engines": {
1111
+ "node": ">= 0.4"
1112
+ },
1113
+ "funding": {
1114
+ "url": "https://github.com/sponsors/ljharb"
1115
+ }
1116
+ },
1117
+ "node_modules/get-proto": {
1118
+ "version": "1.0.1",
1119
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
1120
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1121
+ "license": "MIT",
1122
+ "dependencies": {
1123
+ "dunder-proto": "^1.0.1",
1124
+ "es-object-atoms": "^1.0.0"
1125
+ },
1126
+ "engines": {
1127
+ "node": ">= 0.4"
1128
+ }
1129
+ },
1130
+ "node_modules/gopd": {
1131
+ "version": "1.2.0",
1132
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
1133
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
1134
+ "license": "MIT",
1135
+ "engines": {
1136
+ "node": ">= 0.4"
1137
+ },
1138
+ "funding": {
1139
+ "url": "https://github.com/sponsors/ljharb"
1140
+ }
1141
+ },
1142
+ "node_modules/has-symbols": {
1143
+ "version": "1.1.0",
1144
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
1145
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
1146
+ "license": "MIT",
1147
+ "engines": {
1148
+ "node": ">= 0.4"
1149
+ },
1150
+ "funding": {
1151
+ "url": "https://github.com/sponsors/ljharb"
1152
+ }
1153
+ },
1154
+ "node_modules/hasown": {
1155
+ "version": "2.0.4",
1156
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
1157
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
1158
+ "license": "MIT",
1159
+ "dependencies": {
1160
+ "function-bind": "^1.1.2"
1161
+ },
1162
+ "engines": {
1163
+ "node": ">= 0.4"
1164
+ }
1165
+ },
1166
+ "node_modules/http-errors": {
1167
+ "version": "2.0.1",
1168
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
1169
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
1170
+ "license": "MIT",
1171
+ "dependencies": {
1172
+ "depd": "~2.0.0",
1173
+ "inherits": "~2.0.4",
1174
+ "setprototypeof": "~1.2.0",
1175
+ "statuses": "~2.0.2",
1176
+ "toidentifier": "~1.0.1"
1177
+ },
1178
+ "engines": {
1179
+ "node": ">= 0.8"
1180
+ },
1181
+ "funding": {
1182
+ "type": "opencollective",
1183
+ "url": "https://opencollective.com/express"
1184
+ }
1185
+ },
1186
+ "node_modules/iceberg-js": {
1187
+ "version": "0.8.1",
1188
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
1189
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
1190
+ "license": "MIT",
1191
+ "engines": {
1192
+ "node": ">=20.0.0"
1193
+ }
1194
+ },
1195
+ "node_modules/iconv-lite": {
1196
+ "version": "0.4.24",
1197
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
1198
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
1199
+ "license": "MIT",
1200
+ "dependencies": {
1201
+ "safer-buffer": ">= 2.1.2 < 3"
1202
+ },
1203
+ "engines": {
1204
+ "node": ">=0.10.0"
1205
+ }
1206
+ },
1207
+ "node_modules/inherits": {
1208
+ "version": "2.0.4",
1209
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1210
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1211
+ "license": "ISC"
1212
+ },
1213
+ "node_modules/ipaddr.js": {
1214
+ "version": "1.9.1",
1215
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
1216
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
1217
+ "license": "MIT",
1218
+ "engines": {
1219
+ "node": ">= 0.10"
1220
+ }
1221
+ },
1222
+ "node_modules/isarray": {
1223
+ "version": "1.0.0",
1224
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
1225
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
1226
+ "license": "MIT"
1227
+ },
1228
+ "node_modules/math-intrinsics": {
1229
+ "version": "1.1.0",
1230
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1231
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1232
+ "license": "MIT",
1233
+ "engines": {
1234
+ "node": ">= 0.4"
1235
+ }
1236
+ },
1237
+ "node_modules/media-typer": {
1238
+ "version": "0.3.0",
1239
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
1240
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
1241
+ "license": "MIT",
1242
+ "engines": {
1243
+ "node": ">= 0.6"
1244
+ }
1245
+ },
1246
+ "node_modules/merge-descriptors": {
1247
+ "version": "1.0.3",
1248
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
1249
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
1250
+ "license": "MIT",
1251
+ "funding": {
1252
+ "url": "https://github.com/sponsors/sindresorhus"
1253
+ }
1254
+ },
1255
+ "node_modules/methods": {
1256
+ "version": "1.1.2",
1257
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
1258
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
1259
+ "license": "MIT",
1260
+ "engines": {
1261
+ "node": ">= 0.6"
1262
+ }
1263
+ },
1264
+ "node_modules/mime": {
1265
+ "version": "1.6.0",
1266
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
1267
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
1268
+ "license": "MIT",
1269
+ "bin": {
1270
+ "mime": "cli.js"
1271
+ },
1272
+ "engines": {
1273
+ "node": ">=4"
1274
+ }
1275
+ },
1276
+ "node_modules/mime-db": {
1277
+ "version": "1.52.0",
1278
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1279
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1280
+ "license": "MIT",
1281
+ "engines": {
1282
+ "node": ">= 0.6"
1283
+ }
1284
+ },
1285
+ "node_modules/mime-types": {
1286
+ "version": "2.1.35",
1287
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1288
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1289
+ "license": "MIT",
1290
+ "dependencies": {
1291
+ "mime-db": "1.52.0"
1292
+ },
1293
+ "engines": {
1294
+ "node": ">= 0.6"
1295
+ }
1296
+ },
1297
+ "node_modules/minimist": {
1298
+ "version": "1.2.8",
1299
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1300
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1301
+ "license": "MIT",
1302
+ "funding": {
1303
+ "url": "https://github.com/sponsors/ljharb"
1304
+ }
1305
+ },
1306
+ "node_modules/mkdirp": {
1307
+ "version": "0.5.6",
1308
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
1309
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
1310
+ "license": "MIT",
1311
+ "dependencies": {
1312
+ "minimist": "^1.2.6"
1313
+ },
1314
+ "bin": {
1315
+ "mkdirp": "bin/cmd.js"
1316
+ }
1317
+ },
1318
+ "node_modules/ms": {
1319
+ "version": "2.0.0",
1320
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
1321
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
1322
+ "license": "MIT"
1323
+ },
1324
+ "node_modules/multer": {
1325
+ "version": "1.4.5-lts.2",
1326
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
1327
+ "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
1328
+ "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
1329
+ "license": "MIT",
1330
+ "dependencies": {
1331
+ "append-field": "^1.0.0",
1332
+ "busboy": "^1.0.0",
1333
+ "concat-stream": "^1.5.2",
1334
+ "mkdirp": "^0.5.4",
1335
+ "object-assign": "^4.1.1",
1336
+ "type-is": "^1.6.4",
1337
+ "xtend": "^4.0.0"
1338
+ },
1339
+ "engines": {
1340
+ "node": ">= 6.0.0"
1341
+ }
1342
+ },
1343
+ "node_modules/negotiator": {
1344
+ "version": "0.6.3",
1345
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
1346
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
1347
+ "license": "MIT",
1348
+ "engines": {
1349
+ "node": ">= 0.6"
1350
+ }
1351
+ },
1352
+ "node_modules/node-cron": {
1353
+ "version": "3.0.3",
1354
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
1355
+ "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
1356
+ "license": "ISC",
1357
+ "dependencies": {
1358
+ "uuid": "8.3.2"
1359
+ },
1360
+ "engines": {
1361
+ "node": ">=6.0.0"
1362
+ }
1363
+ },
1364
+ "node_modules/object-assign": {
1365
+ "version": "4.1.1",
1366
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1367
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1368
+ "license": "MIT",
1369
+ "engines": {
1370
+ "node": ">=0.10.0"
1371
+ }
1372
+ },
1373
+ "node_modules/object-inspect": {
1374
+ "version": "1.13.4",
1375
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1376
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1377
+ "license": "MIT",
1378
+ "engines": {
1379
+ "node": ">= 0.4"
1380
+ },
1381
+ "funding": {
1382
+ "url": "https://github.com/sponsors/ljharb"
1383
+ }
1384
+ },
1385
+ "node_modules/on-finished": {
1386
+ "version": "2.4.1",
1387
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1388
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1389
+ "license": "MIT",
1390
+ "dependencies": {
1391
+ "ee-first": "1.1.1"
1392
+ },
1393
+ "engines": {
1394
+ "node": ">= 0.8"
1395
+ }
1396
+ },
1397
+ "node_modules/parseurl": {
1398
+ "version": "1.3.3",
1399
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1400
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1401
+ "license": "MIT",
1402
+ "engines": {
1403
+ "node": ">= 0.8"
1404
+ }
1405
+ },
1406
+ "node_modules/path-to-regexp": {
1407
+ "version": "0.1.13",
1408
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1409
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
1410
+ "license": "MIT"
1411
+ },
1412
+ "node_modules/playwright": {
1413
+ "version": "1.61.1",
1414
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
1415
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
1416
+ "license": "Apache-2.0",
1417
+ "dependencies": {
1418
+ "playwright-core": "1.61.1"
1419
+ },
1420
+ "bin": {
1421
+ "playwright": "cli.js"
1422
+ },
1423
+ "engines": {
1424
+ "node": ">=18"
1425
+ },
1426
+ "optionalDependencies": {
1427
+ "fsevents": "2.3.2"
1428
+ }
1429
+ },
1430
+ "node_modules/playwright-core": {
1431
+ "version": "1.61.1",
1432
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
1433
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
1434
+ "license": "Apache-2.0",
1435
+ "bin": {
1436
+ "playwright-core": "cli.js"
1437
+ },
1438
+ "engines": {
1439
+ "node": ">=18"
1440
+ }
1441
+ },
1442
+ "node_modules/process-nextick-args": {
1443
+ "version": "2.0.1",
1444
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
1445
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
1446
+ "license": "MIT"
1447
+ },
1448
+ "node_modules/proxy-addr": {
1449
+ "version": "2.0.7",
1450
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1451
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1452
+ "license": "MIT",
1453
+ "dependencies": {
1454
+ "forwarded": "0.2.0",
1455
+ "ipaddr.js": "1.9.1"
1456
+ },
1457
+ "engines": {
1458
+ "node": ">= 0.10"
1459
+ }
1460
+ },
1461
+ "node_modules/qs": {
1462
+ "version": "6.15.3",
1463
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
1464
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
1465
+ "license": "BSD-3-Clause",
1466
+ "dependencies": {
1467
+ "es-define-property": "^1.0.1",
1468
+ "side-channel": "^1.1.1"
1469
+ },
1470
+ "engines": {
1471
+ "node": ">=0.6"
1472
+ },
1473
+ "funding": {
1474
+ "url": "https://github.com/sponsors/ljharb"
1475
+ }
1476
+ },
1477
+ "node_modules/range-parser": {
1478
+ "version": "1.2.1",
1479
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1480
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1481
+ "license": "MIT",
1482
+ "engines": {
1483
+ "node": ">= 0.6"
1484
+ }
1485
+ },
1486
+ "node_modules/raw-body": {
1487
+ "version": "2.5.3",
1488
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
1489
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
1490
+ "license": "MIT",
1491
+ "dependencies": {
1492
+ "bytes": "~3.1.2",
1493
+ "http-errors": "~2.0.1",
1494
+ "iconv-lite": "~0.4.24",
1495
+ "unpipe": "~1.0.0"
1496
+ },
1497
+ "engines": {
1498
+ "node": ">= 0.8"
1499
+ }
1500
+ },
1501
+ "node_modules/readable-stream": {
1502
+ "version": "2.3.8",
1503
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
1504
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
1505
+ "license": "MIT",
1506
+ "dependencies": {
1507
+ "core-util-is": "~1.0.0",
1508
+ "inherits": "~2.0.3",
1509
+ "isarray": "~1.0.0",
1510
+ "process-nextick-args": "~2.0.0",
1511
+ "safe-buffer": "~5.1.1",
1512
+ "string_decoder": "~1.1.1",
1513
+ "util-deprecate": "~1.0.1"
1514
+ }
1515
+ },
1516
+ "node_modules/readable-stream/node_modules/safe-buffer": {
1517
+ "version": "5.1.2",
1518
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
1519
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
1520
+ "license": "MIT"
1521
+ },
1522
+ "node_modules/safe-buffer": {
1523
+ "version": "5.2.1",
1524
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1525
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1526
+ "funding": [
1527
+ {
1528
+ "type": "github",
1529
+ "url": "https://github.com/sponsors/feross"
1530
+ },
1531
+ {
1532
+ "type": "patreon",
1533
+ "url": "https://www.patreon.com/feross"
1534
+ },
1535
+ {
1536
+ "type": "consulting",
1537
+ "url": "https://feross.org/support"
1538
+ }
1539
+ ],
1540
+ "license": "MIT"
1541
+ },
1542
+ "node_modules/safer-buffer": {
1543
+ "version": "2.1.2",
1544
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1545
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1546
+ "license": "MIT"
1547
+ },
1548
+ "node_modules/send": {
1549
+ "version": "0.19.2",
1550
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
1551
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
1552
+ "license": "MIT",
1553
+ "dependencies": {
1554
+ "debug": "2.6.9",
1555
+ "depd": "2.0.0",
1556
+ "destroy": "1.2.0",
1557
+ "encodeurl": "~2.0.0",
1558
+ "escape-html": "~1.0.3",
1559
+ "etag": "~1.8.1",
1560
+ "fresh": "~0.5.2",
1561
+ "http-errors": "~2.0.1",
1562
+ "mime": "1.6.0",
1563
+ "ms": "2.1.3",
1564
+ "on-finished": "~2.4.1",
1565
+ "range-parser": "~1.2.1",
1566
+ "statuses": "~2.0.2"
1567
+ },
1568
+ "engines": {
1569
+ "node": ">= 0.8.0"
1570
+ }
1571
+ },
1572
+ "node_modules/send/node_modules/ms": {
1573
+ "version": "2.1.3",
1574
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1575
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1576
+ "license": "MIT"
1577
+ },
1578
+ "node_modules/serve-static": {
1579
+ "version": "1.16.3",
1580
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
1581
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
1582
+ "license": "MIT",
1583
+ "dependencies": {
1584
+ "encodeurl": "~2.0.0",
1585
+ "escape-html": "~1.0.3",
1586
+ "parseurl": "~1.3.3",
1587
+ "send": "~0.19.1"
1588
+ },
1589
+ "engines": {
1590
+ "node": ">= 0.8.0"
1591
+ }
1592
+ },
1593
+ "node_modules/setprototypeof": {
1594
+ "version": "1.2.0",
1595
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1596
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1597
+ "license": "ISC"
1598
+ },
1599
+ "node_modules/side-channel": {
1600
+ "version": "1.1.1",
1601
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
1602
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
1603
+ "license": "MIT",
1604
+ "dependencies": {
1605
+ "es-errors": "^1.3.0",
1606
+ "object-inspect": "^1.13.4",
1607
+ "side-channel-list": "^1.0.1",
1608
+ "side-channel-map": "^1.0.1",
1609
+ "side-channel-weakmap": "^1.0.2"
1610
+ },
1611
+ "engines": {
1612
+ "node": ">= 0.4"
1613
+ },
1614
+ "funding": {
1615
+ "url": "https://github.com/sponsors/ljharb"
1616
+ }
1617
+ },
1618
+ "node_modules/side-channel-list": {
1619
+ "version": "1.0.1",
1620
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1621
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1622
+ "license": "MIT",
1623
+ "dependencies": {
1624
+ "es-errors": "^1.3.0",
1625
+ "object-inspect": "^1.13.4"
1626
+ },
1627
+ "engines": {
1628
+ "node": ">= 0.4"
1629
+ },
1630
+ "funding": {
1631
+ "url": "https://github.com/sponsors/ljharb"
1632
+ }
1633
+ },
1634
+ "node_modules/side-channel-map": {
1635
+ "version": "1.0.1",
1636
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1637
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1638
+ "license": "MIT",
1639
+ "dependencies": {
1640
+ "call-bound": "^1.0.2",
1641
+ "es-errors": "^1.3.0",
1642
+ "get-intrinsic": "^1.2.5",
1643
+ "object-inspect": "^1.13.3"
1644
+ },
1645
+ "engines": {
1646
+ "node": ">= 0.4"
1647
+ },
1648
+ "funding": {
1649
+ "url": "https://github.com/sponsors/ljharb"
1650
+ }
1651
+ },
1652
+ "node_modules/side-channel-weakmap": {
1653
+ "version": "1.0.2",
1654
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1655
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1656
+ "license": "MIT",
1657
+ "dependencies": {
1658
+ "call-bound": "^1.0.2",
1659
+ "es-errors": "^1.3.0",
1660
+ "get-intrinsic": "^1.2.5",
1661
+ "object-inspect": "^1.13.3",
1662
+ "side-channel-map": "^1.0.1"
1663
+ },
1664
+ "engines": {
1665
+ "node": ">= 0.4"
1666
+ },
1667
+ "funding": {
1668
+ "url": "https://github.com/sponsors/ljharb"
1669
+ }
1670
+ },
1671
+ "node_modules/statuses": {
1672
+ "version": "2.0.2",
1673
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
1674
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
1675
+ "license": "MIT",
1676
+ "engines": {
1677
+ "node": ">= 0.8"
1678
+ }
1679
+ },
1680
+ "node_modules/streamsearch": {
1681
+ "version": "1.1.0",
1682
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
1683
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
1684
+ "engines": {
1685
+ "node": ">=10.0.0"
1686
+ }
1687
+ },
1688
+ "node_modules/string_decoder": {
1689
+ "version": "1.1.1",
1690
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
1691
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
1692
+ "license": "MIT",
1693
+ "dependencies": {
1694
+ "safe-buffer": "~5.1.0"
1695
+ }
1696
+ },
1697
+ "node_modules/string_decoder/node_modules/safe-buffer": {
1698
+ "version": "5.1.2",
1699
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
1700
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
1701
+ "license": "MIT"
1702
+ },
1703
+ "node_modules/toidentifier": {
1704
+ "version": "1.0.1",
1705
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1706
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1707
+ "license": "MIT",
1708
+ "engines": {
1709
+ "node": ">=0.6"
1710
+ }
1711
+ },
1712
+ "node_modules/tslib": {
1713
+ "version": "2.8.1",
1714
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1715
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1716
+ "license": "0BSD"
1717
+ },
1718
+ "node_modules/tsx": {
1719
+ "version": "4.22.5",
1720
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz",
1721
+ "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==",
1722
+ "dev": true,
1723
+ "license": "MIT",
1724
+ "dependencies": {
1725
+ "esbuild": "~0.28.0"
1726
+ },
1727
+ "bin": {
1728
+ "tsx": "dist/cli.mjs"
1729
+ },
1730
+ "engines": {
1731
+ "node": ">=18.0.0"
1732
+ },
1733
+ "optionalDependencies": {
1734
+ "fsevents": "~2.3.3"
1735
+ }
1736
+ },
1737
+ "node_modules/tsx/node_modules/fsevents": {
1738
+ "version": "2.3.3",
1739
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1740
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1741
+ "dev": true,
1742
+ "hasInstallScript": true,
1743
+ "license": "MIT",
1744
+ "optional": true,
1745
+ "os": [
1746
+ "darwin"
1747
+ ],
1748
+ "engines": {
1749
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1750
+ }
1751
+ },
1752
+ "node_modules/type-is": {
1753
+ "version": "1.6.18",
1754
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
1755
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
1756
+ "license": "MIT",
1757
+ "dependencies": {
1758
+ "media-typer": "0.3.0",
1759
+ "mime-types": "~2.1.24"
1760
+ },
1761
+ "engines": {
1762
+ "node": ">= 0.6"
1763
+ }
1764
+ },
1765
+ "node_modules/typedarray": {
1766
+ "version": "0.0.6",
1767
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
1768
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
1769
+ "license": "MIT"
1770
+ },
1771
+ "node_modules/typescript": {
1772
+ "version": "5.9.3",
1773
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1774
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1775
+ "dev": true,
1776
+ "license": "Apache-2.0",
1777
+ "bin": {
1778
+ "tsc": "bin/tsc",
1779
+ "tsserver": "bin/tsserver"
1780
+ },
1781
+ "engines": {
1782
+ "node": ">=14.17"
1783
+ }
1784
+ },
1785
+ "node_modules/undici-types": {
1786
+ "version": "6.21.0",
1787
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1788
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1789
+ "dev": true,
1790
+ "license": "MIT"
1791
+ },
1792
+ "node_modules/unpipe": {
1793
+ "version": "1.0.0",
1794
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1795
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1796
+ "license": "MIT",
1797
+ "engines": {
1798
+ "node": ">= 0.8"
1799
+ }
1800
+ },
1801
+ "node_modules/util-deprecate": {
1802
+ "version": "1.0.2",
1803
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1804
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
1805
+ "license": "MIT"
1806
+ },
1807
+ "node_modules/utils-merge": {
1808
+ "version": "1.0.1",
1809
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
1810
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
1811
+ "license": "MIT",
1812
+ "engines": {
1813
+ "node": ">= 0.4.0"
1814
+ }
1815
+ },
1816
+ "node_modules/uuid": {
1817
+ "version": "8.3.2",
1818
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
1819
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
1820
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
1821
+ "license": "MIT",
1822
+ "bin": {
1823
+ "uuid": "dist/bin/uuid"
1824
+ }
1825
+ },
1826
+ "node_modules/vary": {
1827
+ "version": "1.1.2",
1828
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1829
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1830
+ "license": "MIT",
1831
+ "engines": {
1832
+ "node": ">= 0.8"
1833
+ }
1834
+ },
1835
+ "node_modules/xtend": {
1836
+ "version": "4.0.2",
1837
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
1838
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
1839
+ "license": "MIT",
1840
+ "engines": {
1841
+ "node": ">=0.4"
1842
+ }
1843
+ }
1844
+ }
1845
+ }
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "turnitin-worker",
3
+ "version": "1.0.0",
4
+ "description": "Turnitin automation backend worker with Playwright",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "dev": "tsx watch src/index.ts",
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@supabase/supabase-js": "^2.49.0",
14
+ "dotenv": "^16.4.5",
15
+ "express": "^4.21.0",
16
+ "multer": "^1.4.5-lts.1",
17
+ "node-cron": "^3.0.3",
18
+ "playwright": "^1.49.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/express": "^5.0.0",
22
+ "@types/multer": "^1.4.12",
23
+ "@types/node": "^22.0.0",
24
+ "@types/node-cron": "^3.0.11",
25
+ "tsx": "^4.19.0",
26
+ "typescript": "^5.7.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ }
31
+ }
src/config.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+
3
+ export const config = {
4
+ // Supabase
5
+ supabaseUrl: process.env.SUPABASE_URL || '',
6
+ supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '',
7
+
8
+ // Worker
9
+ workerId: process.env.WORKER_ID || `worker-${process.pid}`,
10
+ maxWorkers: parseInt(process.env.MAX_WORKERS || '2', 10),
11
+ pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '3000', 10),
12
+
13
+ // Turnitin
14
+ turnitinSharedPassword: process.env.TURNITIN_SHARED_PASSWORD || '',
15
+ credentialEncryptionKey: process.env.TURNITIN_CREDENTIAL_ENCRYPTION_KEY || '',
16
+ turnitinTargetUrl:
17
+ process.env.TARGET_URL ||
18
+ 'https://www.turnitin.com/login_page.asp?lang=en_us',
19
+ turnitinClassTitle: process.env.CLASS_TITLE || 'Summer Reading 2026',
20
+ turnitinAssignmentTitle: process.env.ASSIGNMENT_TITLE || 'HTLLP notes',
21
+
22
+ // Playwright
23
+ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
24
+
25
+ // Storage buckets
26
+ inputBucket: process.env.INPUT_BUCKET || 'turnitin-inputs',
27
+ reportBucket: process.env.REPORT_BUCKET || 'turnitin-reports',
28
+ diagnosticsBucket: process.env.DIAGNOSTICS_BUCKET || 'turnitin-diagnostics',
29
+ sessionBucket: process.env.SESSION_BUCKET || 'turnitin-sessions',
30
+
31
+ // Cron intervals (minutes)
32
+ quotaCheckInterval: parseInt(process.env.QUOTA_CHECK_INTERVAL || '30', 10),
33
+ quotaCheckAccountDelayMs: parseInt(
34
+ process.env.QUOTA_CHECK_ACCOUNT_DELAY_MS || String(5 * 60 * 1000),
35
+ 10,
36
+ ),
37
+ quotaCheckOrder: process.env.QUOTA_CHECK_ORDER === 'newest' ? 'newest' : 'oldest',
38
+ cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '15', 10),
39
+ staleRecoveryInterval: parseInt(process.env.STALE_RECOVERY_INTERVAL || '5', 10),
40
+
41
+ // Report
42
+ reportRetentionHours: parseInt(process.env.REPORT_RETENTION_HOURS || '24', 10),
43
+
44
+ // Server
45
+ port: parseInt(process.env.PORT || '7860', 10),
46
+ internalSecret: process.env.INTERNAL_SECRET || '',
47
+ allowedOrigins: (process.env.ALLOWED_ORIGINS || process.env.FRONTEND_ORIGIN || '')
48
+ .split(',')
49
+ .map((origin) => origin.trim())
50
+ .filter(Boolean),
51
+ enableWorker: process.env.ENABLE_WORKER !== 'false',
52
+ enableCron: process.env.ENABLE_CRON !== 'false',
53
+
54
+ // Timeouts
55
+ similarityTimeoutMs: parseInt(process.env.SIMILARITY_TIMEOUT_MS || '180000', 10),
56
+ similarityPollMs: parseInt(process.env.SIMILARITY_POLL_MS || '5000', 10),
57
+ similarityRefreshAfterMs: parseInt(process.env.SIMILARITY_REFRESH_AFTER_MS || '90000', 10),
58
+ // BUG-10 FIX: leaseMinutes was hardcoded to 20 min — shorter than the combined
59
+ // time for login + navigate + similarity wait (up to 30+ min). Stale recovery
60
+ // could reset an account that is still being actively used.
61
+ leaseMinutes: parseInt(process.env.LEASE_MINUTES || '35', 10),
62
+ } as const;
63
+
64
+ /** Validate required environment variables at startup */
65
+ export function validateConfig(): void {
66
+ const required: (keyof typeof config)[] = [
67
+ 'supabaseUrl',
68
+ 'supabaseServiceRoleKey',
69
+ 'turnitinSharedPassword',
70
+ ];
71
+
72
+ const missing = required.filter((key) => !config[key]);
73
+ if (missing.length > 0) {
74
+ throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
75
+ }
76
+ }
src/cron/cleanup-reports.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+ /**
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' });
16
+ cronLog.info('Starting report cleanup cron');
17
+
18
+ try {
19
+ const expiredJobs = await getExpiredReportJobs();
20
+
21
+ if (expiredJobs.length === 0) {
22
+ cronLog.info('No expired reports to clean up');
23
+ return;
24
+ }
25
+
26
+ cronLog.info(`Found ${expiredJobs.length} expired reports to clean up`);
27
+
28
+ let deleted = 0;
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,
47
+ level: 'info',
48
+ step: 'report_expired_deleted',
49
+ message: `PDF report deleted after expiry: ${job.output_pdf_path}`,
50
+ metadata: { expired_path: job.output_pdf_path },
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
+
93
+ cronLog.info('Report cleanup cron completed', { deleted, failed });
94
+ } catch (err) {
95
+ cronLog.error('Report cleanup cron failed', {
96
+ error: err instanceof Error ? err.message : String(err),
97
+ });
98
+ }
99
+ }
src/cron/quota-check.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from '../config';
2
+ import { logger } from '../utils/logger';
3
+ import {
4
+ claimSpecificAccount,
5
+ getAccountsForQuotaCheck,
6
+ releaseAccount,
7
+ type TurnitinAccount,
8
+ updateAccountQuota,
9
+ } from '../db/accounts';
10
+ import { downloadStorageState } from '../db/storage';
11
+ import { getAccountPassword } from '../crypto/password';
12
+ import { runTurnitinJob } from '../engine/turnitin';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+
17
+ let quotaCheckCronRunning = false;
18
+
19
+ function sleep(ms: number): Promise<void> {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ function safeReleaseStatus(status: string | null | undefined): string {
24
+ if (status === 'cooling_down' || status === 'quota_limited') return status;
25
+ return 'available';
26
+ }
27
+
28
+ function isTargetUnavailableError(errorMessage: string): boolean {
29
+ return /waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test(
30
+ errorMessage,
31
+ );
32
+ }
33
+
34
+ interface QuotaCheckAccountOptions {
35
+ source?: 'cron' | 'manual';
36
+ fallbackStatus?: string | null;
37
+ }
38
+
39
+ /**
40
+ * Execute the actual Turnitin quota check for an account that has already been
41
+ * leased. Used by both scheduled cron checks and admin-triggered manual checks.
42
+ */
43
+ export async function runQuotaCheckForAccount(
44
+ account: TurnitinAccount,
45
+ options: QuotaCheckAccountOptions = {},
46
+ ): Promise<void> {
47
+ const source = options.source || 'cron';
48
+ const fallbackStatus = safeReleaseStatus(options.fallbackStatus || account.turnitin_status);
49
+ const checkLog = logger.child(
50
+ source === 'manual'
51
+ ? { maintenance: 'manual-quota-check', accountId: account.id }
52
+ : { cron: 'quota-check' },
53
+ );
54
+
55
+ let releaseStatus = fallbackStatus;
56
+ let releaseMessage: string | undefined;
57
+
58
+ try {
59
+ checkLog.info(`Checking quota for account ${account.email}`, {
60
+ accountId: account.id,
61
+ source,
62
+ });
63
+
64
+ let storageStatePath: string | undefined;
65
+ if (account.turnitin_session_storage_path) {
66
+ const storageState = await downloadStorageState(account.turnitin_session_storage_path);
67
+ if (storageState) {
68
+ const tmpDir = path.join(os.tmpdir(), `turnitin-quota-${account.id}`);
69
+ fs.mkdirSync(tmpDir, { recursive: true });
70
+ storageStatePath = path.join(tmpDir, 'storageState.json');
71
+ fs.writeFileSync(storageStatePath, storageState, 'utf-8');
72
+ }
73
+ }
74
+
75
+ const password = getAccountPassword();
76
+
77
+ const result = await runTurnitinJob({
78
+ account: {
79
+ id: account.id,
80
+ email: account.email,
81
+ password,
82
+ },
83
+ assignmentTarget: {
84
+ targetUrl: config.turnitinTargetUrl,
85
+ classTitle: config.turnitinClassTitle,
86
+ assignmentTitle: config.turnitinAssignmentTitle,
87
+ },
88
+ inputFilePath: '',
89
+ outputDir: '',
90
+ mode: 'quota_check',
91
+ filters: {},
92
+ storageStatePath,
93
+ onEvent: async (event) => {
94
+ checkLog.info(`[${account.email}] ${event.step}: ${event.message}`);
95
+ },
96
+ });
97
+
98
+ if (result.quotaLimit) {
99
+ await updateAccountQuota(account.id, {
100
+ turnitin_status: 'quota_limited',
101
+ turnitin_quota_remaining: 0,
102
+ turnitin_quota_message: result.quotaLimit.message,
103
+ turnitin_quota_detected_at: new Date().toISOString(),
104
+ turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
105
+ turnitin_last_checked_at: new Date().toISOString(),
106
+ });
107
+ releaseStatus = 'quota_limited';
108
+ releaseMessage = result.quotaLimit.message;
109
+ checkLog.info(`Account ${account.email} is quota limited`, {
110
+ accountId: account.id,
111
+ source,
112
+ });
113
+ } else {
114
+ await updateAccountQuota(account.id, {
115
+ turnitin_status: 'available',
116
+ turnitin_quota_message: null,
117
+ turnitin_last_checked_at: new Date().toISOString(),
118
+ });
119
+ releaseStatus = 'available';
120
+
121
+ if (fallbackStatus === 'cooling_down' || fallbackStatus === 'quota_limited') {
122
+ await updateAccountQuota(account.id, {
123
+ turnitin_next_retry_at: null,
124
+ turnitin_quota_remaining: null,
125
+ });
126
+ checkLog.info(`Account ${account.email} recovered from cooldown`, {
127
+ accountId: account.id,
128
+ source,
129
+ });
130
+ }
131
+ }
132
+ } catch (err) {
133
+ const errorMessage = err instanceof Error ? err.message : String(err);
134
+ checkLog.error(`Quota check failed for account ${account.email}`, {
135
+ accountId: account.id,
136
+ source,
137
+ error: errorMessage,
138
+ });
139
+
140
+ try {
141
+ if (isTargetUnavailableError(errorMessage)) {
142
+ await updateAccountQuota(account.id, {
143
+ turnitin_status: 'quota_limited',
144
+ turnitin_quota_remaining: 0,
145
+ turnitin_quota_message:
146
+ 'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.',
147
+ turnitin_quota_detected_at: new Date().toISOString(),
148
+ turnitin_next_retry_at: null,
149
+ turnitin_last_checked_at: new Date().toISOString(),
150
+ turnitin_last_error: errorMessage,
151
+ });
152
+ releaseStatus = 'quota_limited';
153
+ } else {
154
+ await updateAccountQuota(account.id, {
155
+ turnitin_last_checked_at: new Date().toISOString(),
156
+ turnitin_last_error: errorMessage,
157
+ });
158
+ }
159
+ } catch {
160
+ // Ignore update error; release still runs below.
161
+ }
162
+
163
+ releaseMessage = errorMessage;
164
+ } finally {
165
+ await releaseAccount(account.id, releaseStatus, releaseMessage).catch((releaseErr: unknown) => {
166
+ checkLog.error(`Failed to release account ${account.email} after quota check`, {
167
+ accountId: account.id,
168
+ source,
169
+ error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
170
+ });
171
+ });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Periodic quota check for Turnitin accounts.
177
+ * Runs every QUOTA_CHECK_INTERVAL minutes.
178
+ *
179
+ * Checks:
180
+ * 1. Available accounts not checked recently
181
+ * 2. Cooling-down accounts whose next_retry_at has passed
182
+ */
183
+ export async function runQuotaCheckCron(): Promise<void> {
184
+ const cronLog = logger.child({ cron: 'quota-check' });
185
+ if (quotaCheckCronRunning) {
186
+ cronLog.info('Previous quota check cron is still running; skipping this tick');
187
+ return;
188
+ }
189
+
190
+ quotaCheckCronRunning = true;
191
+ cronLog.info('Starting quota check cron');
192
+
193
+ try {
194
+ const accounts = await getAccountsForQuotaCheck(config.quotaCheckOrder, 'modern_lti');
195
+
196
+ if (accounts.length === 0) {
197
+ cronLog.info('No accounts need quota check');
198
+ return;
199
+ }
200
+
201
+ cronLog.info(`Found ${accounts.length} accounts to check`, {
202
+ order: config.quotaCheckOrder,
203
+ accountDelayMs: config.quotaCheckAccountDelayMs,
204
+ });
205
+
206
+ // Process accounts sequentially to avoid overwhelming the browser
207
+ for (let index = 0; index < accounts.length; index++) {
208
+ const account = accounts[index];
209
+ try {
210
+ const claimed = await claimSpecificAccount(
211
+ account.id,
212
+ `${config.workerId}:quota-check`,
213
+ );
214
+ if (!claimed) {
215
+ cronLog.info(`Skipping account already leased ${account.email}`, {
216
+ accountId: account.id,
217
+ });
218
+ continue;
219
+ }
220
+
221
+ await runQuotaCheckForAccount(account, {
222
+ source: 'cron',
223
+ fallbackStatus: account.turnitin_status || 'available',
224
+ });
225
+ } catch (outerErr) {
226
+ // This catches errors from claimSpecificAccount itself or variable setup
227
+ cronLog.error(`Outer error processing account ${account.email}`, {
228
+ accountId: account.id,
229
+ error: outerErr instanceof Error ? outerErr.message : String(outerErr),
230
+ });
231
+ }
232
+
233
+ if (index < accounts.length - 1 && config.quotaCheckAccountDelayMs > 0) {
234
+ cronLog.info('Waiting before next quota account check', {
235
+ delayMs: config.quotaCheckAccountDelayMs,
236
+ nextIndex: index + 2,
237
+ total: accounts.length,
238
+ });
239
+ await sleep(config.quotaCheckAccountDelayMs);
240
+ }
241
+ }
242
+
243
+ cronLog.info('Quota check cron completed');
244
+ } catch (err) {
245
+ cronLog.error('Quota check cron failed', {
246
+ error: err instanceof Error ? err.message : String(err),
247
+ });
248
+ } finally {
249
+ quotaCheckCronRunning = false;
250
+ }
251
+ }
src/cron/stale-recovery.ts ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from '../config';
2
+ import { logger } from '../utils/logger';
3
+ import { getStaleJobs, updateJobStatus } from '../db/jobs';
4
+ import {
5
+ claimLegacyCooldownAccountForClassDrop,
6
+ getLegacyCooldownAccountsForClassDrop,
7
+ getStaleAccounts,
8
+ releaseAccount,
9
+ resetStaleAccount,
10
+ updateAccountQuota,
11
+ } from '../db/accounts';
12
+ import { insertJobEvent } from '../db/events';
13
+ import { refundFailedJob } from '../db/tickets';
14
+ import { supabase } from '../db/client';
15
+ import { downloadStorageState } from '../db/storage';
16
+ import { getAccountPassword } from '../crypto/password';
17
+ import { getBrowser } from '../worker/browser-pool';
18
+ import { loginToTurnitin } from '../engine/steps/login';
19
+ import { dropClassByTitle } from '../engine/steps/class-management';
20
+ import * as fs from 'fs';
21
+ import * as os from 'os';
22
+ import * as path from 'path';
23
+ import type { BrowserContext } from 'playwright';
24
+
25
+ async function getLegacyClassTitles(): Promise<string[]> {
26
+ const titles = new Set<string>();
27
+
28
+ const { data, error } = await supabase
29
+ .from('turnitin_assignment_targets')
30
+ .select('class_title, ui_variant, account_pool_key')
31
+ .or('ui_variant.eq.legacy_carta,account_pool_key.eq.legacy_carta');
32
+
33
+ if (error) {
34
+ logger.warn('Failed to read legacy assignment targets for class cleanup; using default class title', {
35
+ error: error.message,
36
+ });
37
+ }
38
+
39
+ for (const row of data || []) {
40
+ const title = typeof row.class_title === 'string' ? row.class_title.trim() : '';
41
+ if (title) titles.add(title);
42
+ }
43
+
44
+ if (titles.size === 0 && config.turnitinClassTitle.trim()) {
45
+ titles.add(config.turnitinClassTitle.trim());
46
+ }
47
+
48
+ return Array.from(titles);
49
+ }
50
+
51
+ async function dropLegacyCooldownAccountClass(
52
+ accountId: string,
53
+ classTitles: string[],
54
+ ): Promise<boolean> {
55
+ const cleanupLog = logger.child({ cron: 'legacy-cooldown-class-cleanup', accountId });
56
+ const account = await claimLegacyCooldownAccountForClassDrop(
57
+ accountId,
58
+ `${config.workerId}:legacy-class-cleanup`,
59
+ );
60
+
61
+ if (!account) {
62
+ cleanupLog.info('Legacy cooldown account was already claimed or is no longer eligible');
63
+ return false;
64
+ }
65
+
66
+ let context: BrowserContext | null = null;
67
+ let storageStatePath: string | undefined;
68
+
69
+ try {
70
+ cleanupLog.info('Cleaning up overdue legacy cooldown account', {
71
+ email: account.email,
72
+ classTitles,
73
+ });
74
+
75
+ if (account.turnitin_session_storage_path) {
76
+ const storageState = await downloadStorageState(account.turnitin_session_storage_path);
77
+ if (storageState) {
78
+ const tmpDir = path.join(os.tmpdir(), `turnitin-legacy-class-cleanup-${account.id}`);
79
+ fs.mkdirSync(tmpDir, { recursive: true });
80
+ storageStatePath = path.join(tmpDir, 'storageState.json');
81
+ fs.writeFileSync(storageStatePath, storageState, 'utf-8');
82
+ }
83
+ }
84
+
85
+ const browser = await getBrowser();
86
+ context = await browser.newContext({
87
+ userAgent:
88
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
89
+ viewport: { width: 1366, height: 768 },
90
+ extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
91
+ ...(storageStatePath ? { storageState: storageStatePath } : {}),
92
+ });
93
+
94
+ const page = await context.newPage();
95
+ await loginToTurnitin(
96
+ page,
97
+ account.email,
98
+ getAccountPassword(),
99
+ storageStatePath,
100
+ config.turnitinTargetUrl,
101
+ );
102
+
103
+ const dropResults = [];
104
+ for (const classTitle of classTitles) {
105
+ const result = await dropClassByTitle(page, classTitle).catch((error: unknown) => ({
106
+ attempted: true,
107
+ dropped: false,
108
+ reason: error instanceof Error ? error.message : String(error),
109
+ }));
110
+ dropResults.push({ classTitle, ...result });
111
+ if (result.dropped) break;
112
+ }
113
+
114
+ const successfulDrop = dropResults.some((result) => result.dropped);
115
+ if (!successfulDrop) {
116
+ const message = `Legacy cooldown class cleanup could not drop any configured class: ${dropResults
117
+ .map((result) => `${result.classTitle}: ${result.reason || 'not dropped'}`)
118
+ .join('; ')}`;
119
+ cleanupLog.warn(message);
120
+ await updateAccountQuota(account.id, {
121
+ turnitin_status: 'cooling_down',
122
+ turnitin_quota_message: 'Legacy cooldown class cleanup pending.',
123
+ turnitin_next_retry_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
124
+ turnitin_last_error: message,
125
+ turnitin_last_checked_at: new Date().toISOString(),
126
+ });
127
+ await releaseAccount(account.id, 'cooling_down', message);
128
+ return false;
129
+ }
130
+
131
+ const message =
132
+ 'Legacy account was already cooling down before permanent cleanup; class dropped and account permanently limited.';
133
+ await updateAccountQuota(account.id, {
134
+ turnitin_status: 'quota_limited',
135
+ turnitin_quota_remaining: 0,
136
+ turnitin_quota_message: message,
137
+ turnitin_quota_detected_at: new Date().toISOString(),
138
+ turnitin_next_retry_at: null,
139
+ turnitin_last_error: null,
140
+ turnitin_last_checked_at: new Date().toISOString(),
141
+ });
142
+ await releaseAccount(account.id, 'quota_limited', message);
143
+ cleanupLog.info('Legacy cooldown account permanently limited after class cleanup', {
144
+ dropResults,
145
+ });
146
+ return true;
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ const isLoginError = /login/i.test(message);
150
+ cleanupLog.error('Legacy cooldown class cleanup failed', { error: message });
151
+ await updateAccountQuota(account.id, {
152
+ turnitin_status: isLoginError ? 'quota_limited' : 'cooling_down',
153
+ turnitin_quota_remaining: isLoginError ? 0 : account.turnitin_quota_remaining,
154
+ turnitin_quota_message: isLoginError
155
+ ? 'Legacy class cleanup could not log in; account removed from rotation.'
156
+ : 'Legacy class cleanup failed and will retry later.',
157
+ turnitin_quota_detected_at: isLoginError ? new Date().toISOString() : account.turnitin_quota_detected_at,
158
+ turnitin_next_retry_at: isLoginError
159
+ ? null
160
+ : new Date(Date.now() + 60 * 60 * 1000).toISOString(),
161
+ turnitin_last_error: message,
162
+ turnitin_last_checked_at: new Date().toISOString(),
163
+ });
164
+ await releaseAccount(account.id, isLoginError ? 'quota_limited' : 'cooling_down', message);
165
+ return false;
166
+ } finally {
167
+ await context?.close().catch(() => {});
168
+ }
169
+ }
170
+
171
+ async function cleanupLegacyCooldownAccounts(): Promise<number> {
172
+ const cleanupLog = logger.child({ cron: 'legacy-cooldown-class-cleanup' });
173
+ const classTitles = await getLegacyClassTitles();
174
+ if (classTitles.length === 0) {
175
+ cleanupLog.warn('No class titles configured for legacy cooldown cleanup');
176
+ return 0;
177
+ }
178
+
179
+ const accounts = await getLegacyCooldownAccountsForClassDrop(Math.max(1, Math.min(2, config.maxWorkers)));
180
+ if (accounts.length === 0) return 0;
181
+
182
+ cleanupLog.warn(`Found ${accounts.length} overdue legacy cooldown accounts for class cleanup`, {
183
+ classTitles,
184
+ });
185
+
186
+ let cleaned = 0;
187
+ for (const account of accounts) {
188
+ if (await dropLegacyCooldownAccountClass(account.id, classTitles)) {
189
+ cleaned += 1;
190
+ }
191
+ }
192
+
193
+ return cleaned;
194
+ }
195
+
196
+ /**
197
+ * Periodic recovery of stale leases.
198
+ * Runs every STALE_RECOVERY_INTERVAL minutes.
199
+ *
200
+ * Handles two cases:
201
+ * 1. Accounts stuck in 'running' with expired leases (worker died)
202
+ * 2. Jobs stuck in running states for too long (worker died mid-job)
203
+ */
204
+ export async function runStaleRecoveryCron(): Promise<void> {
205
+ const cronLog = logger.child({ cron: 'stale-recovery' });
206
+ cronLog.info('Starting stale recovery cron');
207
+
208
+ try {
209
+ // 1. Recover stale accounts (lease expired but still 'running')
210
+ const staleAccounts = await getStaleAccounts(config.leaseMinutes + 5);
211
+
212
+ if (staleAccounts.length > 0) {
213
+ cronLog.warn(`Found ${staleAccounts.length} stale accounts to recover`);
214
+
215
+ for (const account of staleAccounts) {
216
+ try {
217
+ await resetStaleAccount(account.id);
218
+ cronLog.info(`Recovered stale account ${account.email}`, {
219
+ accountId: account.id,
220
+ leaseOwner: account.turnitin_lease_owner,
221
+ });
222
+ } catch (err) {
223
+ cronLog.error(`Failed to recover stale account ${account.id}`, {
224
+ error: err instanceof Error ? err.message : String(err),
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ // 2. Recover stale jobs (stuck in running states for too long)
231
+ const staleJobs = await getStaleJobs(config.leaseMinutes + 5);
232
+
233
+ if (staleJobs.length > 0) {
234
+ cronLog.warn(`Found ${staleJobs.length} stale jobs to recover`);
235
+
236
+ for (const job of staleJobs) {
237
+ try {
238
+ const attemptCount = (job.attempt_count as number) || 0;
239
+ const maxAttempts = (job.max_attempts as number) || 3;
240
+
241
+ if (attemptCount < maxAttempts) {
242
+ // Reset to pending for retry
243
+ await updateJobStatus(job.id as string, 'pending', {
244
+ error_message: 'Job recovered from stale state (worker may have died)',
245
+ });
246
+ await insertJobEvent({
247
+ job_id: job.id as string,
248
+ identity_id: null,
249
+ level: 'warning',
250
+ step: 'stale_recovery',
251
+ message: `Job reset to pending after stale detection (attempt ${attemptCount}/${maxAttempts})`,
252
+ metadata: { worker_id: job.worker_id, stale_status: job.status },
253
+ });
254
+ cronLog.info(`Reset stale job ${job.id} to pending`);
255
+ } else {
256
+ // Max attempts reached, mark as failed
257
+ const refundReason = 'Job failed after stale recovery: max attempts reached';
258
+ await updateJobStatus(job.id as string, 'failed', {
259
+ error_message: refundReason,
260
+ finished_at: new Date().toISOString(),
261
+ });
262
+ let refunded = false;
263
+ try {
264
+ refunded = await refundFailedJob(job.id as string, refundReason);
265
+ } catch (refundError) {
266
+ cronLog.warn(`Failed to refund stale job ${job.id}`, {
267
+ error: refundError instanceof Error ? refundError.message : String(refundError),
268
+ });
269
+ }
270
+ await insertJobEvent({
271
+ job_id: job.id as string,
272
+ identity_id: null,
273
+ level: 'error',
274
+ step: 'stale_recovery_failed',
275
+ message: refunded
276
+ ? `Job marked as failed after stale recovery (${maxAttempts} attempts exhausted). Ticket refunded.`
277
+ : `Job marked as failed after stale recovery (${maxAttempts} attempts exhausted)`,
278
+ metadata: { worker_id: job.worker_id, refunded },
279
+ });
280
+ cronLog.warn(`Marked stale job ${job.id} as failed (max attempts reached)`);
281
+ }
282
+ } catch (err) {
283
+ cronLog.error(`Failed to recover stale job ${job.id}`, {
284
+ error: err instanceof Error ? err.message : String(err),
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ const cleanedLegacyCooldownAccounts = await cleanupLegacyCooldownAccounts();
291
+
292
+ if (staleAccounts.length === 0 && staleJobs.length === 0 && cleanedLegacyCooldownAccounts === 0) {
293
+ cronLog.info('No stale resources found');
294
+ }
295
+
296
+ cronLog.info('Stale recovery cron completed');
297
+ } catch (err) {
298
+ cronLog.error('Stale recovery cron failed', {
299
+ error: err instanceof Error ? err.message : String(err),
300
+ });
301
+ }
302
+ }
src/crypto/password.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as crypto from 'crypto';
2
+ import { config } from '../config';
3
+
4
+ const ALGORITHM = 'aes-256-gcm';
5
+ const IV_LENGTH = 16;
6
+ const AUTH_TAG_LENGTH = 16;
7
+
8
+ /**
9
+ * Derive a 32-byte key from the encryption key string.
10
+ * Uses SHA-256 so any length passphrase works.
11
+ */
12
+ function deriveKey(): Buffer {
13
+ return crypto.createHash('sha256').update(config.credentialEncryptionKey).digest();
14
+ }
15
+
16
+ /**
17
+ * Encrypt a plaintext password.
18
+ * Returns base64 string: iv(16) + authTag(16) + ciphertext
19
+ */
20
+ export function encryptPassword(plaintext: string): string {
21
+ const key = deriveKey();
22
+ const iv = crypto.randomBytes(IV_LENGTH);
23
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
24
+
25
+ const encrypted = Buffer.concat([
26
+ cipher.update(plaintext, 'utf8'),
27
+ cipher.final(),
28
+ ]);
29
+
30
+ const authTag = cipher.getAuthTag();
31
+
32
+ // Format: iv + authTag + ciphertext
33
+ const combined = Buffer.concat([iv, authTag, encrypted]);
34
+ return combined.toString('base64');
35
+ }
36
+
37
+ /**
38
+ * Decrypt an encrypted password string.
39
+ * Input is base64 string: iv(16) + authTag(16) + ciphertext
40
+ */
41
+ export function decryptPassword(encrypted: string): string {
42
+ const key = deriveKey();
43
+ const combined = Buffer.from(encrypted, 'base64');
44
+
45
+ const iv = combined.subarray(0, IV_LENGTH);
46
+ const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
47
+ const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
48
+
49
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
50
+ decipher.setAuthTag(authTag);
51
+
52
+ const decrypted = Buffer.concat([
53
+ decipher.update(ciphertext),
54
+ decipher.final(),
55
+ ]);
56
+
57
+ return decrypted.toString('utf8');
58
+ }
59
+
60
+ /**
61
+ * Get the Turnitin password for an account.
62
+ * If encryption key is set, tries to decrypt. Otherwise uses shared password.
63
+ */
64
+ export function getAccountPassword(encryptedPassword?: string | null): string {
65
+ if (encryptedPassword && config.credentialEncryptionKey) {
66
+ try {
67
+ return decryptPassword(encryptedPassword);
68
+ } catch {
69
+ // Fallback to shared password if decryption fails
70
+ }
71
+ }
72
+ return config.turnitinSharedPassword;
73
+ }
src/db/accounts.ts ADDED
@@ -0,0 +1,700 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { supabase } from './client';
2
+ import { config } from '../config';
3
+ import { logger } from '../utils/logger';
4
+
5
+ export interface TurnitinAccount {
6
+ id: string;
7
+ email: string;
8
+ password?: string | null;
9
+ turnitin_status: string;
10
+ turnitin_quota_limit: number | null;
11
+ turnitin_quota_remaining: number | null;
12
+ turnitin_quota_message: string | null;
13
+ turnitin_quota_detected_at: string | null;
14
+ turnitin_first_submission_at: string | null;
15
+ turnitin_next_retry_at: string | null;
16
+ turnitin_lease_owner: string | null;
17
+ turnitin_lease_until: string | null;
18
+ turnitin_last_checked_at: string | null;
19
+ turnitin_last_login_at: string | null;
20
+ turnitin_last_success_at: string | null;
21
+ turnitin_last_error: string | null;
22
+ turnitin_session_storage_path: string | null;
23
+ turnitin_pool_key: string | null;
24
+ created_at: string;
25
+ updated_at: string;
26
+ }
27
+
28
+ export interface AccountQuotaUpdate {
29
+ turnitin_status?: string;
30
+ turnitin_quota_limit?: number | null;
31
+ turnitin_quota_remaining?: number | null;
32
+ turnitin_quota_message?: string | null;
33
+ turnitin_quota_detected_at?: string | null;
34
+ turnitin_next_retry_at?: string | null;
35
+ turnitin_last_checked_at?: string | null;
36
+ turnitin_last_success_at?: string | null;
37
+ turnitin_last_error?: string | null;
38
+ turnitin_session_storage_path?: string | null;
39
+ turnitin_pool_key?: string | null;
40
+ }
41
+
42
+ export interface AccountPoolState {
43
+ total: number;
44
+ available: number;
45
+ running: number;
46
+ coolingDown: number;
47
+ quotaLimited: number;
48
+ loginFailed: number;
49
+ disabled: number;
50
+ }
51
+
52
+ function isLegacyHardLimitMessage(account: {
53
+ turnitin_quota_message?: string | null;
54
+ turnitin_last_error?: string | null;
55
+ }): boolean {
56
+ const text = `${account.turnitin_quota_message || ''} ${account.turnitin_last_error || ''}`.toLowerCase();
57
+ return (
58
+ text.includes('target class or assignment is not available') ||
59
+ text.includes('login failed') ||
60
+ text.includes('could not log in') ||
61
+ text.includes('credential is invalid') ||
62
+ text.includes('account no longer exists') ||
63
+ /class.*drop/.test(text) ||
64
+ /class.*not.*found/.test(text) ||
65
+ /assignment.*not.*found/.test(text) ||
66
+ text.includes('permanently limited') ||
67
+ text.includes('reached 4 submissions') ||
68
+ text.includes('4-submission limit')
69
+ );
70
+ }
71
+
72
+ function isClaimableLegacyAccount(account: TurnitinAccount, nowMs = Date.now()): boolean {
73
+ const status = account.turnitin_status || 'available';
74
+ const quotaLimit = account.turnitin_quota_limit;
75
+ const quotaRemaining = account.turnitin_quota_remaining;
76
+ if (typeof quotaLimit === 'number' && quotaLimit <= 0) return false;
77
+ const hasQuota =
78
+ typeof quotaRemaining === 'number'
79
+ ? quotaRemaining > 0
80
+ : status !== 'quota_limited';
81
+ const leaseReady =
82
+ !account.turnitin_lease_until || new Date(account.turnitin_lease_until).getTime() <= nowMs;
83
+ const retryReady =
84
+ !account.turnitin_next_retry_at || new Date(account.turnitin_next_retry_at).getTime() <= nowMs;
85
+
86
+ if (!hasQuota || !leaseReady || !retryReady) return false;
87
+ if (status === 'available' || status === 'cooling_down' || status === 'running') return true;
88
+ if (status === 'quota_limited') return typeof quotaRemaining === 'number' && !isLegacyHardLimitMessage(account);
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * Atomically claim an available Turnitin account via database RPC.
94
+ * Returns the claimed account row, or null if none are available.
95
+ */
96
+ export async function claimAvailableAccount(
97
+ workerId: string,
98
+ poolKey = 'modern_lti',
99
+ ): Promise<TurnitinAccount | null> {
100
+ // NEW-BUG-2 FIX: Reduced from 20 to 3. When all accounts are stale/cooldown,
101
+ // spinning 20 times just wastes DB calls. The manager's wait-for-account loop
102
+ // handles the retry with proper backoff.
103
+ const MAX_STALE_RETRIES = 3;
104
+ for (let attempt = 0; attempt < MAX_STALE_RETRIES; attempt++) {
105
+ let { data, error } = await supabase.rpc('claim_turnitin_identity', {
106
+ p_worker_id: workerId,
107
+ p_pool_key: poolKey,
108
+ });
109
+
110
+ if (
111
+ error &&
112
+ poolKey === 'modern_lti' &&
113
+ /function|schema cache|p_pool_key|claim_turnitin_identity/i.test(error.message)
114
+ ) {
115
+ logger.warn('Pool-aware claim RPC is not migrated yet; falling back to legacy modern claim RPC', {
116
+ error: error.message,
117
+ });
118
+ const fallback = await supabase.rpc('claim_turnitin_identity', {
119
+ p_worker_id: workerId,
120
+ });
121
+ data = fallback.data;
122
+ error = fallback.error;
123
+ }
124
+
125
+ if (error) {
126
+ logger.error('Failed to claim available account', { poolKey, error: error.message });
127
+ throw error;
128
+ }
129
+
130
+ if (!data || (Array.isArray(data) && data.length === 0)) {
131
+ return null;
132
+ }
133
+
134
+ const account = Array.isArray(data) ? data[0] : data;
135
+ const nextRetryAt = account.turnitin_next_retry_at
136
+ ? new Date(account.turnitin_next_retry_at).getTime()
137
+ : null;
138
+ const quotaRemaining = account.turnitin_quota_remaining;
139
+ const quotaLimit = account.turnitin_quota_limit;
140
+ const staleAvailable =
141
+ (typeof quotaRemaining === 'number' && quotaRemaining <= 0) ||
142
+ (typeof quotaLimit === 'number' && quotaLimit <= 0) ||
143
+ (nextRetryAt !== null && nextRetryAt > Date.now());
144
+
145
+ if (!staleAvailable) {
146
+ return account;
147
+ }
148
+
149
+ const exhaustedQuota =
150
+ (typeof quotaRemaining === 'number' && quotaRemaining <= 0) ||
151
+ (typeof quotaLimit === 'number' && quotaLimit <= 0);
152
+ const nextStatus = exhaustedQuota ? 'quota_limited' : 'cooling_down';
153
+ const message = exhaustedQuota
154
+ ? 'Account has no remaining Turnitin quota and was removed from rotation.'
155
+ : 'Account quota is not currently available.';
156
+
157
+ logger.warn('Claimed stale unavailable account; removing from immediate rotation', {
158
+ accountId: account.id,
159
+ poolKey,
160
+ quotaLimit,
161
+ quotaRemaining,
162
+ nextRetryAt: account.turnitin_next_retry_at,
163
+ nextStatus,
164
+ });
165
+
166
+ await updateAccountQuota(account.id, {
167
+ turnitin_status: nextStatus,
168
+ turnitin_quota_remaining: 0,
169
+ turnitin_quota_message: message,
170
+ turnitin_next_retry_at: exhaustedQuota
171
+ ? null
172
+ : account.turnitin_next_retry_at ||
173
+ new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
174
+ turnitin_last_error: 'Skipped stale account during claim.',
175
+ });
176
+ await releaseAccount(account.id, nextStatus, 'Skipped stale account during claim.');
177
+ }
178
+
179
+ return null;
180
+ }
181
+
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,
191
+ workerId: string,
192
+ ): Promise<TurnitinAccount | null> {
193
+ const now = new Date().toISOString();
194
+ const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
195
+
196
+ const { data, error } = await supabase
197
+ .from('generated_identities')
198
+ .update({
199
+ turnitin_status: 'running',
200
+ turnitin_lease_owner: workerId,
201
+ turnitin_lease_until: leaseUntil,
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();
209
+
210
+ if (error) {
211
+ logger.error('Failed to claim specific resume account', {
212
+ identityId,
213
+ error: error.message,
214
+ });
215
+ throw error;
216
+ }
217
+
218
+ return (data as TurnitinAccount | null) || null;
219
+ }
220
+
221
+ /**
222
+ * Lease a specific account for maintenance/quota checks. Returns false when
223
+ * another worker already owns the lease or the account is not claimable.
224
+ */
225
+ export async function claimSpecificAccount(
226
+ identityId: string,
227
+ workerId: string,
228
+ ): Promise<boolean> {
229
+ const now = new Date().toISOString();
230
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
231
+ const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
232
+
233
+ const { data, error } = await supabase
234
+ .from('generated_identities')
235
+ .update({
236
+ turnitin_status: 'running',
237
+ turnitin_lease_owner: workerId,
238
+ turnitin_lease_until: leaseUntil,
239
+ turnitin_last_checked_at: now,
240
+ updated_at: now,
241
+ })
242
+ .eq('id', identityId)
243
+ .in('turnitin_status', ['available', 'cooling_down'])
244
+ .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
245
+ .or(
246
+ [
247
+ `and(turnitin_status.eq.available,or(turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0),or(turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}))`,
248
+ `and(turnitin_status.eq.cooling_down,turnitin_next_retry_at.lte.${now})`,
249
+ ].join(','),
250
+ )
251
+ .select('id')
252
+ .maybeSingle();
253
+
254
+ if (error) {
255
+ logger.error('Failed to claim specific account', {
256
+ identityId,
257
+ error: error.message,
258
+ });
259
+ throw error;
260
+ }
261
+
262
+ return Boolean(data);
263
+ }
264
+
265
+ /**
266
+ * Read legacy accounts that were already cooling down before the permanent-limit
267
+ * cleanup existed. Only accounts whose cooldown window has elapsed are returned,
268
+ * so we do not interfere with an active post-submit retry.
269
+ */
270
+ export async function getLegacyCooldownAccountsForClassDrop(
271
+ limit = 2,
272
+ ): Promise<TurnitinAccount[]> {
273
+ const now = new Date().toISOString();
274
+
275
+ const { data, error } = await supabase
276
+ .from('generated_identities')
277
+ .select('*')
278
+ .eq('turnitin_pool_key', 'legacy_carta')
279
+ .eq('turnitin_status', 'cooling_down')
280
+ .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
281
+ .order('turnitin_next_retry_at', { ascending: true, nullsFirst: true })
282
+ .limit(limit);
283
+
284
+ if (error) {
285
+ logger.error('Failed to read legacy cooldown accounts for class cleanup', {
286
+ error: error.message,
287
+ });
288
+ throw error;
289
+ }
290
+
291
+ return (data as TurnitinAccount[]) || [];
292
+ }
293
+
294
+ /**
295
+ * Lease one overdue legacy cooldown account for permanent cleanup. This is
296
+ * intentionally stricter than generic resume claiming and only targets
297
+ * legacy_carta/cooling_down rows whose cooldown has elapsed.
298
+ */
299
+ export async function claimLegacyCooldownAccountForClassDrop(
300
+ identityId: string,
301
+ workerId: string,
302
+ ): Promise<TurnitinAccount | null> {
303
+ const now = new Date().toISOString();
304
+ const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
305
+
306
+ const { data, error } = await supabase
307
+ .from('generated_identities')
308
+ .update({
309
+ turnitin_status: 'running',
310
+ turnitin_lease_owner: workerId,
311
+ turnitin_lease_until: leaseUntil,
312
+ turnitin_last_checked_at: now,
313
+ updated_at: now,
314
+ })
315
+ .eq('id', identityId)
316
+ .eq('turnitin_pool_key', 'legacy_carta')
317
+ .eq('turnitin_status', 'cooling_down')
318
+ .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
319
+ .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
320
+ .select('*')
321
+ .maybeSingle();
322
+
323
+ if (error) {
324
+ logger.error('Failed to claim legacy cooldown account for class cleanup', {
325
+ identityId,
326
+ error: error.message,
327
+ });
328
+ throw error;
329
+ }
330
+
331
+ return (data as TurnitinAccount | null) || null;
332
+ }
333
+
334
+ /**
335
+ * Read one Turnitin account by id. Used by admin maintenance routes before
336
+ * attempting a manual lease so the previous status can be restored on failure.
337
+ */
338
+ export async function getTurnitinAccountById(
339
+ identityId: string,
340
+ ): Promise<TurnitinAccount | null> {
341
+ const { data, error } = await supabase
342
+ .from('generated_identities')
343
+ .select('*')
344
+ .eq('id', identityId)
345
+ .maybeSingle();
346
+
347
+ if (error) {
348
+ logger.error('Failed to read Turnitin account', {
349
+ identityId,
350
+ error: error.message,
351
+ });
352
+ throw error;
353
+ }
354
+
355
+ return (data as TurnitinAccount | null) || null;
356
+ }
357
+
358
+ /**
359
+ * Lease a specific account for an admin-triggered manual quota check.
360
+ *
361
+ * This intentionally bypasses the "checked in the last 30 minutes" guard used by
362
+ * scheduled quota checks, but it still refuses disabled/login-failed accounts and
363
+ * any account with an active lease.
364
+ */
365
+ export async function claimSpecificAccountForManualQuota(
366
+ identityId: string,
367
+ workerId: string,
368
+ ): Promise<TurnitinAccount | null> {
369
+ const now = new Date().toISOString();
370
+ const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
371
+
372
+ const { data, error } = await supabase
373
+ .from('generated_identities')
374
+ .update({
375
+ turnitin_status: 'running',
376
+ turnitin_lease_owner: workerId,
377
+ turnitin_lease_until: leaseUntil,
378
+ turnitin_last_checked_at: now,
379
+ updated_at: now,
380
+ })
381
+ .eq('id', identityId)
382
+ .not('turnitin_status', 'in', '(disabled,login_failed)')
383
+ .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
384
+ .select('*')
385
+ .maybeSingle();
386
+
387
+ if (error) {
388
+ logger.error('Failed to claim account for manual quota check', {
389
+ identityId,
390
+ error: error.message,
391
+ });
392
+ throw error;
393
+ }
394
+
395
+ return (data as TurnitinAccount | null) || null;
396
+ }
397
+
398
+ /**
399
+ * Count accounts that are immediately claimable. Used to avoid hot-looping a
400
+ * job when every account is in cooldown/quota_limited state.
401
+ */
402
+ export async function countAvailableAccounts(poolKey?: string): Promise<number> {
403
+ const now = new Date().toISOString();
404
+ if (poolKey === 'legacy_carta') {
405
+ const { data, error } = await supabase
406
+ .from('generated_identities')
407
+ .select('*')
408
+ .eq('turnitin_pool_key', poolKey)
409
+ .in('turnitin_status', ['available', 'cooling_down', 'running', 'quota_limited'])
410
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
411
+ .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
412
+ .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
413
+ .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`);
414
+
415
+ if (error) {
416
+ logger.error('Failed to count legacy available accounts', { poolKey, error: error.message });
417
+ throw error;
418
+ }
419
+
420
+ const nowMs = Date.now();
421
+ return ((data as TurnitinAccount[]) || []).filter((account) =>
422
+ isClaimableLegacyAccount(account, nowMs),
423
+ ).length;
424
+ }
425
+
426
+ let query = supabase
427
+ .from('generated_identities')
428
+ .select('id', { count: 'exact', head: true })
429
+ .eq('turnitin_status', 'available')
430
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
431
+ .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
432
+ .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
433
+ .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`);
434
+
435
+ if (poolKey) {
436
+ query = query.eq('turnitin_pool_key', poolKey);
437
+ }
438
+
439
+ const { count, error } = await query;
440
+
441
+ if (error) {
442
+ if (
443
+ poolKey === 'modern_lti' &&
444
+ /turnitin_pool_key|column/i.test(error.message)
445
+ ) {
446
+ logger.warn('turnitin_pool_key column is not migrated yet; counting all modern accounts without pool filter');
447
+ return countAvailableAccounts();
448
+ }
449
+ logger.error('Failed to count available accounts', { poolKey, error: error.message });
450
+ throw error;
451
+ }
452
+
453
+ return count || 0;
454
+ }
455
+
456
+ /**
457
+ * Read a compact account pool snapshot for scheduling decisions. The
458
+ * `available` value is the claimable count, not merely rows with status
459
+ * available, so stale zero-quota accounts do not keep jobs looping.
460
+ */
461
+ export async function getAccountPoolState(poolKey?: string): Promise<AccountPoolState> {
462
+ let rowsQuery = supabase
463
+ .from('generated_identities')
464
+ .select('turnitin_status');
465
+
466
+ if (poolKey) {
467
+ rowsQuery = rowsQuery.eq('turnitin_pool_key', poolKey);
468
+ }
469
+
470
+ const [available, { data, error }] = await Promise.all([
471
+ countAvailableAccounts(poolKey),
472
+ rowsQuery,
473
+ ]);
474
+
475
+ if (error) {
476
+ if (
477
+ poolKey === 'modern_lti' &&
478
+ /turnitin_pool_key|column/i.test(error.message)
479
+ ) {
480
+ logger.warn('turnitin_pool_key column is not migrated yet; reading global account pool state');
481
+ return getAccountPoolState();
482
+ }
483
+ logger.error('Failed to read account pool state', { poolKey, error: error.message });
484
+ throw error;
485
+ }
486
+
487
+ const rows = (data || []) as Array<{ turnitin_status: string | null }>;
488
+ const counts: AccountPoolState = {
489
+ total: rows.length,
490
+ available,
491
+ running: 0,
492
+ coolingDown: 0,
493
+ quotaLimited: 0,
494
+ loginFailed: 0,
495
+ disabled: 0,
496
+ };
497
+
498
+ for (const row of rows) {
499
+ const status = row.turnitin_status || 'available';
500
+ if (status === 'running') counts.running++;
501
+ else if (status === 'cooling_down') counts.coolingDown++;
502
+ else if (status === 'quota_limited') counts.quotaLimited++;
503
+ else if (status === 'login_failed') counts.loginFailed++;
504
+ else if (status === 'disabled') counts.disabled++;
505
+ }
506
+
507
+ return counts;
508
+ }
509
+
510
+ /**
511
+ * Release a claimed account back to the pool via database RPC.
512
+ * Sets the next status and optionally records an error message.
513
+ */
514
+ export async function releaseAccount(
515
+ identityId: string,
516
+ nextStatus: string,
517
+ errorMessage?: string,
518
+ ): Promise<void> {
519
+ const { error } = await supabase.rpc('release_turnitin_identity', {
520
+ p_identity_id: identityId,
521
+ p_next_status: nextStatus,
522
+ p_error_message: errorMessage || null,
523
+ });
524
+
525
+ if (error) {
526
+ logger.error('Failed to release account', { identityId, error: error.message });
527
+ throw error;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Update quota-related fields on a Turnitin account.
533
+ */
534
+ export async function updateAccountQuota(
535
+ identityId: string,
536
+ update: Partial<AccountQuotaUpdate>,
537
+ ): Promise<void> {
538
+ const { error } = await supabase
539
+ .from('generated_identities')
540
+ .update({ ...update, updated_at: new Date().toISOString() })
541
+ .eq('id', identityId);
542
+
543
+ if (error) {
544
+ logger.error('Failed to update account quota', { identityId, error: error.message });
545
+ throw error;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Get accounts that are due for a quota check:
551
+ * - Available accounts not checked in the last 30 minutes
552
+ * - Cooling-down accounts whose next_retry_at has passed
553
+ */
554
+ export async function getAccountsForQuotaCheck(
555
+ order: 'oldest' | 'newest' = 'oldest',
556
+ poolKey = 'modern_lti',
557
+ ): Promise<TurnitinAccount[]> {
558
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
559
+ const now = new Date().toISOString();
560
+
561
+ // Available accounts not checked recently
562
+ let availableQuery = supabase
563
+ .from('generated_identities')
564
+ .select('*')
565
+ .eq('turnitin_status', 'available')
566
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
567
+ .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
568
+ .or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`);
569
+
570
+ if (poolKey) {
571
+ availableQuery = availableQuery.eq('turnitin_pool_key', poolKey);
572
+ }
573
+
574
+ let { data: available, error: err1 } = await availableQuery;
575
+
576
+ if (
577
+ err1 &&
578
+ poolKey === 'modern_lti' &&
579
+ /turnitin_pool_key|column/i.test(err1.message)
580
+ ) {
581
+ logger.warn('turnitin_pool_key column is not migrated yet; quota check will use global modern account query');
582
+ const fallback = await supabase
583
+ .from('generated_identities')
584
+ .select('*')
585
+ .eq('turnitin_status', 'available')
586
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
587
+ .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
588
+ .or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`);
589
+ available = fallback.data;
590
+ err1 = fallback.error;
591
+ }
592
+
593
+ if (err1) {
594
+ logger.error('Failed to get available accounts for quota check', { error: err1.message });
595
+ throw err1;
596
+ }
597
+
598
+ // Cooling-down accounts ready to retry
599
+ let coolingDownQuery = supabase
600
+ .from('generated_identities')
601
+ .select('*')
602
+ .eq('turnitin_status', 'cooling_down')
603
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
604
+ .lte('turnitin_next_retry_at', now);
605
+
606
+ if (poolKey) {
607
+ coolingDownQuery = coolingDownQuery.eq('turnitin_pool_key', poolKey);
608
+ }
609
+
610
+ let { data: coolingDown, error: err2 } = await coolingDownQuery;
611
+
612
+ if (
613
+ err2 &&
614
+ poolKey === 'modern_lti' &&
615
+ /turnitin_pool_key|column/i.test(err2.message)
616
+ ) {
617
+ const fallback = await supabase
618
+ .from('generated_identities')
619
+ .select('*')
620
+ .eq('turnitin_status', 'cooling_down')
621
+ .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
622
+ .lte('turnitin_next_retry_at', now);
623
+ coolingDown = fallback.data;
624
+ err2 = fallback.error;
625
+ }
626
+
627
+ if (err2) {
628
+ logger.error('Failed to get cooling-down accounts for quota check', { error: err2.message });
629
+ throw err2;
630
+ }
631
+
632
+ const accounts = [
633
+ ...((available as TurnitinAccount[]) || []),
634
+ ...((coolingDown as TurnitinAccount[]) || []),
635
+ ];
636
+
637
+ return accounts.sort((a, b) => {
638
+ const aChecked = a.turnitin_last_checked_at
639
+ ? new Date(a.turnitin_last_checked_at).getTime()
640
+ : null;
641
+ const bChecked = b.turnitin_last_checked_at
642
+ ? new Date(b.turnitin_last_checked_at).getTime()
643
+ : null;
644
+
645
+ if (aChecked === null && bChecked === null) {
646
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
647
+ }
648
+
649
+ if (order === 'oldest') {
650
+ if (aChecked === null) return -1;
651
+ if (bChecked === null) return 1;
652
+ return aChecked - bChecked;
653
+ }
654
+
655
+ if (aChecked === null) return 1;
656
+ if (bChecked === null) return -1;
657
+ return bChecked - aChecked;
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Find accounts with expired leases that are still in 'running' status.
663
+ */
664
+ export async function getStaleAccounts(staleSinceMinutes: number): Promise<TurnitinAccount[]> {
665
+ const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString();
666
+
667
+ const { data, error } = await supabase
668
+ .from('generated_identities')
669
+ .select('*')
670
+ .eq('turnitin_status', 'running')
671
+ .lt('turnitin_lease_until', cutoff);
672
+
673
+ if (error) {
674
+ logger.error('Failed to get stale accounts', { error: error.message });
675
+ throw error;
676
+ }
677
+
678
+ return (data as TurnitinAccount[]) || [];
679
+ }
680
+
681
+ /**
682
+ * Reset a stale account back to 'available' status.
683
+ */
684
+ export async function resetStaleAccount(identityId: string): Promise<void> {
685
+ const { error } = await supabase
686
+ .from('generated_identities')
687
+ .update({
688
+ turnitin_status: 'available',
689
+ turnitin_lease_owner: null,
690
+ turnitin_lease_until: null,
691
+ turnitin_last_error: null,
692
+ updated_at: new Date().toISOString(),
693
+ })
694
+ .eq('id', identityId);
695
+
696
+ if (error) {
697
+ logger.error('Failed to reset stale account', { identityId, error: error.message });
698
+ throw error;
699
+ }
700
+ }
src/db/client.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { config } from '../config';
3
+
4
+ export const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
5
+ auth: { autoRefreshToken: false, persistSession: false },
6
+ });
src/db/events.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { supabase } from './client';
2
+ import { logger } from '../utils/logger';
3
+
4
+ export interface JobEvent {
5
+ id: string;
6
+ job_id: string;
7
+ identity_id: string | null;
8
+ level: 'info' | 'warning' | 'error';
9
+ step: string;
10
+ message: string;
11
+ metadata: Record<string, unknown> | null;
12
+ created_at: string;
13
+ }
14
+
15
+ export interface JobEventInsert {
16
+ job_id: string;
17
+ identity_id: string | null;
18
+ level: 'info' | 'warning' | 'error';
19
+ step: string;
20
+ message: string;
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+
24
+ /**
25
+ * Insert a new event into the job event log.
26
+ */
27
+ export async function insertJobEvent(event: JobEventInsert): Promise<void> {
28
+ const { error } = await supabase
29
+ .from('turnitin_job_events')
30
+ .insert({
31
+ job_id: event.job_id,
32
+ identity_id: event.identity_id,
33
+ level: event.level,
34
+ step: event.step,
35
+ message: event.message,
36
+ metadata: event.metadata || {},
37
+ });
38
+
39
+ if (error) {
40
+ logger.error('Failed to insert job event', {
41
+ jobId: event.job_id,
42
+ step: event.step,
43
+ error: error.message,
44
+ });
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Retrieve all events for a given job, ordered by creation time.
51
+ */
52
+ export async function getJobEvents(jobId: string): Promise<JobEvent[]> {
53
+ const { data, error } = await supabase
54
+ .from('turnitin_job_events')
55
+ .select('*')
56
+ .eq('job_id', jobId)
57
+ .order('created_at', { ascending: true });
58
+
59
+ if (error) {
60
+ logger.error('Failed to get job events', { jobId, error: error.message });
61
+ throw error;
62
+ }
63
+
64
+ return (data as JobEvent[]) || [];
65
+ }
src/db/jobs.ts ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { supabase } from './client';
2
+ import { logger } from '../utils/logger';
3
+
4
+ export interface TurnitinJob {
5
+ id: string;
6
+ user_id: string;
7
+ identity_id: string | null;
8
+ assignment_target_id: string;
9
+ mode: string;
10
+ status: string;
11
+ input_file_path: string;
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;
23
+ similarity_percent: number | null;
24
+ last_completed_step: string | null;
25
+ submission_details: Record<string, unknown> | null;
26
+ filters: Record<string, unknown>;
27
+ attempt_count: number;
28
+ max_attempts: number;
29
+ next_retry_at: string | null;
30
+ error_message: string | null;
31
+ worker_id: string | null;
32
+ started_at: string | null;
33
+ finished_at: string | null;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ /**
39
+ * Atomically claim a pending job using the database RPC.
40
+ * Returns the claimed job row, or null if no jobs are available.
41
+ */
42
+ export async function claimPendingJob(workerId: string): Promise<TurnitinJob | null> {
43
+ const { data, error } = await supabase.rpc('claim_turnitin_job', {
44
+ p_worker_id: workerId,
45
+ });
46
+
47
+ if (error) {
48
+ logger.error('Failed to claim pending job', { error: error.message });
49
+ throw error;
50
+ }
51
+
52
+ if (!data || (Array.isArray(data) && data.length === 0)) {
53
+ return null;
54
+ }
55
+
56
+ return Array.isArray(data) ? data[0] : data;
57
+ }
58
+
59
+ /**
60
+ * Update a job's status and optionally merge additional column values.
61
+ */
62
+ export async function updateJobStatus(
63
+ jobId: string,
64
+ status: string,
65
+ extra?: Partial<TurnitinJob>,
66
+ ): Promise<void> {
67
+ const update: Record<string, unknown> = {
68
+ status,
69
+ updated_at: new Date().toISOString(),
70
+ ...extra,
71
+ };
72
+
73
+ const { error } = await supabase
74
+ .from('turnitin_jobs')
75
+ .update(update)
76
+ .eq('id', jobId);
77
+
78
+ if (error) {
79
+ logger.error('Failed to update job status', { jobId, status, error: error.message });
80
+ throw error;
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.
118
+ */
119
+ export async function updateJobFields(
120
+ jobId: string,
121
+ fields: Partial<TurnitinJob>,
122
+ ): Promise<void> {
123
+ if (Object.keys(fields).length === 0) return;
124
+
125
+ const update: Record<string, unknown> = {
126
+ ...fields,
127
+ updated_at: new Date().toISOString(),
128
+ };
129
+
130
+ const { error } = await supabase
131
+ .from('turnitin_jobs')
132
+ .update(update)
133
+ .eq('id', jobId);
134
+
135
+ if (error) {
136
+ logger.error('Failed to update job fields', {
137
+ jobId,
138
+ fields: Object.keys(fields),
139
+ error: error.message,
140
+ });
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Fetch a single job by its ID.
147
+ */
148
+ export async function getJobById(jobId: string): Promise<TurnitinJob | null> {
149
+ const { data, error } = await supabase
150
+ .from('turnitin_jobs')
151
+ .select('*')
152
+ .eq('id', jobId)
153
+ .single();
154
+
155
+ if (error) {
156
+ if (error.code === 'PGRST116') return null; // Row not found
157
+ logger.error('Failed to get job by id', { jobId, error: error.message });
158
+ throw error;
159
+ }
160
+
161
+ return data as TurnitinJob;
162
+ }
163
+
164
+ /**
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 });
179
+ throw error;
180
+ }
181
+
182
+ return (data as TurnitinJob[]) || [];
183
+ }
184
+
185
+ /**
186
+ * Atomically increment the attempt_count for a job and return the new value.
187
+ *
188
+ * BUG-1 FIX: The previous Read-Modify-Write approach was a race condition —
189
+ * two Space workers could both read attempt_count=0, compute 1, and both
190
+ * write 1, meaning the limit was never properly enforced.
191
+ *
192
+ * Fix: Use optimistic locking. We UPDATE with a WHERE attempt_count = expected.
193
+ * If the row was modified by another worker concurrently, the update matches
194
+ * 0 rows and we re-read the current value (which already contains the
195
+ * concurrent increment) and return that.
196
+ */
197
+ export async function incrementJobAttempt(jobId: string): Promise<number> {
198
+ const job = await getJobById(jobId);
199
+ if (!job) {
200
+ throw new Error(`Job not found: ${jobId}`);
201
+ }
202
+
203
+ const expectedCount = (job.attempt_count as number) || 0;
204
+ const newCount = expectedCount + 1;
205
+
206
+ const { data, error } = await supabase
207
+ .from('turnitin_jobs')
208
+ .update({ attempt_count: newCount, updated_at: new Date().toISOString() })
209
+ .eq('id', jobId)
210
+ .eq('attempt_count', expectedCount) // optimistic lock
211
+ .select('attempt_count');
212
+
213
+ if (error) {
214
+ logger.error('Failed to increment job attempt', { jobId, error: error.message });
215
+ throw error;
216
+ }
217
+
218
+ // If no row was updated another worker already incremented; re-read true value.
219
+ if (!data || data.length === 0) {
220
+ logger.warn('incrementJobAttempt: concurrent update detected; re-reading actual count', { jobId });
221
+ const fresh = await getJobById(jobId);
222
+ return (fresh?.attempt_count as number) || newCount;
223
+ }
224
+
225
+ return (data[0] as { attempt_count: number }).attempt_count;
226
+ }
227
+
228
+ /**
229
+ * Find jobs that are stuck in running-like states for longer than the given threshold.
230
+ */
231
+ export async function getStaleJobs(staleSinceMinutes: number): Promise<TurnitinJob[]> {
232
+ const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString();
233
+
234
+ // In our scheme, jobs in running states have started_at or updated_at, we can check updated_at
235
+ const { data, error } = await supabase
236
+ .from('turnitin_jobs')
237
+ .select('*')
238
+ .in('status', [
239
+ 'claiming_account',
240
+ 'waiting_account',
241
+ 'running',
242
+ 'uploading',
243
+ 'submitted',
244
+ 'waiting_similarity',
245
+ 'opening_viewer',
246
+ 'applying_filters',
247
+ 'downloading',
248
+ ])
249
+ .lt('updated_at', cutoff);
250
+
251
+ if (error) {
252
+ logger.error('Failed to get stale jobs', { error: error.message });
253
+ throw error;
254
+ }
255
+
256
+ return (data as TurnitinJob[]) || [];
257
+ }
src/db/storage.ts ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { supabase } from './client';
4
+ import { config } from '../config';
5
+ import { logger } from '../utils/logger';
6
+
7
+ /**
8
+ * Upload a user's input file to the turnitin-inputs bucket.
9
+ * Returns the storage path within the bucket.
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) {
29
+ logger.error('Failed to upload input file', { storagePath, error: error.message });
30
+ throw error;
31
+ }
32
+
33
+ return storagePath;
34
+ }
35
+
36
+ /**
37
+ * Download a file from Supabase Storage to a local path.
38
+ */
39
+ export async function downloadInputFile(storagePath: string, localPath: string): Promise<void> {
40
+ const { data, error } = await supabase.storage
41
+ .from(config.inputBucket)
42
+ .download(storagePath);
43
+
44
+ if (error) {
45
+ logger.error('Failed to download input file', { storagePath, error: error.message });
46
+ throw error;
47
+ }
48
+
49
+ const buffer = Buffer.from(await data.arrayBuffer());
50
+ const dir = path.dirname(localPath);
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+ fs.writeFileSync(localPath, buffer);
55
+ }
56
+
57
+ /**
58
+ * Upload a generated report PDF to the turnitin-reports bucket.
59
+ * Returns the storage path and expiry timestamp.
60
+ */
61
+ export async function uploadReportPdf(
62
+ userId: string,
63
+ jobId: string,
64
+ localPdfPath: string,
65
+ ): Promise<{ storagePath: string; expiresAt: string }> {
66
+ const storagePath = `${userId}/${jobId}/report.pdf`;
67
+ const fileBuffer = fs.readFileSync(localPdfPath);
68
+
69
+ const { error } = await supabase.storage
70
+ .from(config.reportBucket)
71
+ .upload(storagePath, fileBuffer, {
72
+ contentType: 'application/pdf',
73
+ upsert: true,
74
+ });
75
+
76
+ if (error) {
77
+ logger.error('Failed to upload report PDF', { storagePath, error: error.message });
78
+ throw error;
79
+ }
80
+
81
+ const expiresAt = new Date(
82
+ Date.now() + config.reportRetentionHours * 60 * 60 * 1000,
83
+ ).toISOString();
84
+
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
+ */
125
+ export async function deleteReportPdf(storagePath: string): Promise<void> {
126
+ const { error } = await supabase.storage
127
+ .from(config.reportBucket)
128
+ .remove([storagePath]);
129
+
130
+ if (error) {
131
+ logger.error('Failed to delete report PDF', { storagePath, error: error.message });
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Upload a Playwright browser storage state to the sessions bucket.
138
+ * Returns the storage path.
139
+ */
140
+ export async function uploadStorageState(
141
+ accountId: string,
142
+ stateJson: string,
143
+ ): Promise<string> {
144
+ const storagePath = `${accountId}/state.json`;
145
+
146
+ const { error } = await supabase.storage
147
+ .from(config.sessionBucket)
148
+ .upload(storagePath, Buffer.from(stateJson, 'utf-8'), {
149
+ contentType: 'application/json',
150
+ upsert: true,
151
+ });
152
+
153
+ if (error) {
154
+ logger.error('Failed to upload storage state', { accountId, error: error.message });
155
+ throw error;
156
+ }
157
+
158
+ return storagePath;
159
+ }
160
+
161
+ /**
162
+ * Download a previously saved storage state.
163
+ * Returns the JSON string, or null if not found.
164
+ */
165
+ export async function downloadStorageState(storagePath: string): Promise<string | null> {
166
+ const { data, error } = await supabase.storage
167
+ .from(config.sessionBucket)
168
+ .download(storagePath);
169
+
170
+ if (error) {
171
+ // Not found is not fatal — the account may not have a saved session
172
+ if (error.message?.includes('not found') || error.message?.includes('Object not found')) {
173
+ return null;
174
+ }
175
+ logger.error('Failed to download storage state', { storagePath, error: error.message });
176
+ throw error;
177
+ }
178
+
179
+ return await data.text();
180
+ }
181
+
182
+ /**
183
+ * Create a time-limited signed URL for a file in any bucket.
184
+ */
185
+ export async function createSignedUrl(
186
+ bucket: string,
187
+ filePath: string,
188
+ expiresInSeconds: number,
189
+ downloadFileName?: string,
190
+ ): Promise<string> {
191
+ const { data, error } = await supabase.storage
192
+ .from(bucket)
193
+ .createSignedUrl(
194
+ filePath,
195
+ expiresInSeconds,
196
+ downloadFileName ? { download: downloadFileName } : undefined,
197
+ );
198
+
199
+ if (error) {
200
+ logger.error('Failed to create signed URL', { bucket, filePath, error: error.message });
201
+ throw error;
202
+ }
203
+
204
+ return data.signedUrl;
205
+ }
206
+
207
+ /** Map file extensions to MIME types */
208
+ function getMimeType(ext: string): string {
209
+ const mimeTypes: Record<string, string> = {
210
+ '.pdf': 'application/pdf',
211
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
212
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
213
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
214
+ '.ps': 'application/postscript',
215
+ '.html': 'text/html',
216
+ '.txt': 'text/plain',
217
+ '.rtf': 'application/rtf',
218
+ '.odt': 'application/vnd.oasis.opendocument.text',
219
+ '.hwp': 'application/x-hwp',
220
+ };
221
+ return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
222
+ }
src/db/tickets.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
7
+ assignmentTargetId: string;
8
+ mode: string;
9
+ filters: Record<string, unknown>;
10
+ inputFileName: 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 {
23
+ id: string;
24
+ email: string;
25
+ display_name: string | null;
26
+ ticket_balance: number;
27
+ role: string;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ /**
33
+ * Create a new Turnitin job while atomically deducting a ticket.
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) {
51
+ logger.error('Failed to create job with ticket', { error: error.message, userId: params.userId });
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
+ /**
89
+ * Fetch a user's profile including their current ticket balance.
90
+ */
91
+ export async function getUserProfile(userId: string): Promise<UserProfile | null> {
92
+ const { data, error } = await supabase
93
+ .from('user_profiles')
94
+ .select('*')
95
+ .eq('id', userId)
96
+ .single();
97
+
98
+ if (error) {
99
+ if (error.code === 'PGRST116') return null; // Row not found
100
+ logger.error('Failed to get user profile', { userId, error: error.message });
101
+ throw error;
102
+ }
103
+
104
+ return data as UserProfile;
105
+ }
106
+
107
+ /**
108
+ * Admin operation: top up a user's ticket balance.
109
+ * Uses database RPC for atomic increment. Returns the new balance.
110
+ */
111
+ export async function adminTopupTickets(
112
+ adminId: string,
113
+ targetUserId: string,
114
+ amount: number,
115
+ ): Promise<number> {
116
+ const { data, error } = await supabase.rpc('admin_topup_tickets', {
117
+ p_admin_id: adminId,
118
+ p_target_user_id: targetUserId,
119
+ p_amount: amount,
120
+ });
121
+
122
+ if (error) {
123
+ logger.error('Failed to top up tickets', {
124
+ adminId,
125
+ targetUserId,
126
+ amount,
127
+ error: error.message,
128
+ });
129
+ throw error;
130
+ }
131
+
132
+ return data as number;
133
+ }
134
+
135
+ /**
136
+ * Cancel a pending job and refund the ticket.
137
+ * Uses database RPC for atomic status change + ticket refund.
138
+ * Returns true if the job was successfully cancelled.
139
+ */
140
+ export async function cancelJob(userId: string, jobId: string): Promise<boolean> {
141
+ const { data, error } = await supabase.rpc('cancel_job', {
142
+ p_user_id: userId,
143
+ p_job_id: jobId,
144
+ });
145
+
146
+ if (error) {
147
+ logger.error('Failed to cancel job', { userId, jobId, error: error.message });
148
+ throw error;
149
+ }
150
+
151
+ return data as boolean;
152
+ }
153
+
154
+ /**
155
+ * Refund a terminal failed job exactly once.
156
+ *
157
+ * The database RPC locks the job row and checks the ticket ledger before
158
+ * incrementing balance, so this remains idempotent across multiple workers,
159
+ * stale recovery, and manual/admin retries.
160
+ */
161
+ export async function refundFailedJob(jobId: string, reason: string): Promise<boolean> {
162
+ const { data, error } = await supabase.rpc('refund_failed_job', {
163
+ p_job_id: jobId,
164
+ p_reason: reason,
165
+ });
166
+
167
+ if (error) {
168
+ logger.error('Failed to refund failed job', {
169
+ jobId,
170
+ reason,
171
+ error: error.message,
172
+ });
173
+ throw error;
174
+ }
175
+
176
+ return data as boolean;
177
+ }
src/engine/legacy.ts ADDED
@@ -0,0 +1,1917 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { BrowserContext, Download, Page } from 'playwright';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { config } from '../config';
5
+ import { logger } from '../utils/logger';
6
+ import { getBrowser } from '../worker/browser-pool';
7
+ import { loginToTurnitin, acceptEulaEverywhere } from './steps/login';
8
+ import { navigateToAssignment } from './steps/navigate';
9
+ import { dropClassByTitle } from './steps/class-management';
10
+ import { hasActiveFilters } from './steps/filters';
11
+ import type { FilterOptions } from './steps/filters';
12
+ import type { RunTurnitinJobInput, RunTurnitinJobResult } from './turnitin';
13
+ import type { SubmissionDetails } from './steps/submission-details';
14
+ import type { Frame } from 'playwright';
15
+
16
+ type Scope = Page | Frame;
17
+
18
+ type EngineEvent = NonNullable<RunTurnitinJobInput['onEvent']>;
19
+ const LEGACY_ACCOUNT_QUOTA_LIMIT = 4;
20
+
21
+ async function emit(
22
+ onEvent: RunTurnitinJobInput['onEvent'],
23
+ level: 'info' | 'warning' | 'error',
24
+ step: string,
25
+ message: string,
26
+ metadata?: Record<string, unknown>,
27
+ ): Promise<void> {
28
+ if (onEvent) {
29
+ await onEvent({ level, step, message, metadata }).catch(() => {});
30
+ }
31
+ if (level === 'error') logger.error(message, { step, ...metadata });
32
+ else if (level === 'warning') logger.warn(message, { step, ...metadata });
33
+ else logger.info(message, { step, ...metadata });
34
+ }
35
+
36
+ function compactErrorMessage(message: string): string {
37
+ const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
38
+ if (/locator\.waitFor: Timeout/i.test(firstLine)) {
39
+ const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
40
+ return selector
41
+ ? `Turnitin legacy page element did not appear in time: ${selector}`
42
+ : 'Turnitin legacy page element did not appear in time.';
43
+ }
44
+ return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
45
+ }
46
+
47
+ function parseNumber(value: string | null | undefined): number | undefined {
48
+ const numeric = Number(String(value || '').replace(/[^\d]/g, ''));
49
+ return Number.isFinite(numeric) ? numeric : undefined;
50
+ }
51
+
52
+ function parsePercent(value: string | null | undefined): number | null {
53
+ const match = String(value || '').match(/(\d{1,3})\s*%?/);
54
+ if (!match) return null;
55
+ const parsed = Number(match[1]);
56
+ return Number.isFinite(parsed) ? parsed : null;
57
+ }
58
+
59
+ function parseFileSizeBytes(value: string | null | undefined): number | undefined {
60
+ const text = String(value || '').trim();
61
+ const match = text.match(/([\d.,]+)\s*(bytes?|b|kb|kib|mb|mib)?/i);
62
+ if (!match) return parseNumber(text);
63
+ const amount = Number(match[1].replace(/,/g, ''));
64
+ if (!Number.isFinite(amount)) return parseNumber(text);
65
+ const unit = (match[2] || 'b').toLowerCase();
66
+ if (unit === 'mb' || unit === 'mib') return Math.round(amount * 1024 * 1024);
67
+ if (unit === 'kb' || unit === 'kib') return Math.round(amount * 1024);
68
+ return Math.round(amount);
69
+ }
70
+
71
+ function normalizeLabel(label: string): keyof SubmissionDetails | null {
72
+ const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
73
+ if (normalized === 'student id') return 'studentId';
74
+ if (normalized === 'class name') return 'className';
75
+ if (normalized === 'class id') return 'classId';
76
+ if (normalized === 'submission id') return 'submissionId';
77
+ if (normalized === 'submission date') return 'submissionDate';
78
+ if (normalized === 'submission count') return 'submissionCount';
79
+ if (normalized === 'file name') return 'fileName';
80
+ if (normalized === 'file extension') return 'fileExtension';
81
+ if (normalized === 'file size') return 'fileSize';
82
+ if (normalized === 'character count') return 'charCount';
83
+ if (normalized === 'char count') return 'charCount';
84
+ if (normalized === 'word count') return 'wordCount';
85
+ if (normalized === 'page count') return 'pageCount';
86
+ return null;
87
+ }
88
+
89
+ async function waitForAnyDownload(
90
+ context: BrowserContext,
91
+ timeoutMs: number,
92
+ ): Promise<Download> {
93
+ return new Promise((resolve, reject) => {
94
+ const timer = setTimeout(
95
+ () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)),
96
+ timeoutMs,
97
+ );
98
+ const pageListeners = new Map<Page, (d: Download) => void>();
99
+
100
+ const onDownload = (download: Download) => cleanup(null, download);
101
+ const onPage = (p: Page) => attach(p);
102
+
103
+ function attach(p: Page) {
104
+ p.on('download', onDownload);
105
+ pageListeners.set(p, onDownload);
106
+ }
107
+
108
+ function cleanup(error: Error | null, download?: Download) {
109
+ clearTimeout(timer);
110
+ context.off('page', onPage);
111
+ for (const [p, listener] of pageListeners) p.off('download', listener);
112
+ if (error) reject(error);
113
+ else resolve(download!);
114
+ }
115
+
116
+ for (const p of context.pages()) attach(p);
117
+ context.on('page', onPage);
118
+ });
119
+ }
120
+
121
+ async function visible(page: Scope, selector: string, timeout = 2500): Promise<boolean> {
122
+ try {
123
+ await page.locator(selector).first().waitFor({ state: 'visible', timeout });
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ async function findLegacyAssignmentScope(
131
+ page: Page,
132
+ selector: string,
133
+ timeoutMs = 20000,
134
+ ): Promise<Page | Frame | null> {
135
+ const deadline = Date.now() + timeoutMs;
136
+ while (true) {
137
+ if (await page.locator(selector).first().isVisible().catch(() => false)) {
138
+ return page;
139
+ }
140
+
141
+ for (const frame of page.frames()) {
142
+ if (frame.url().includes('cookie-shim')) continue;
143
+ if (await frame.locator(selector).first().isVisible().catch(() => false)) {
144
+ return frame;
145
+ }
146
+ }
147
+
148
+ if (Date.now() >= deadline) break;
149
+ await page.waitForTimeout(500).catch(() => {});
150
+ }
151
+
152
+ return null;
153
+ }
154
+
155
+ async function clickFirstVisible(
156
+ page: Scope,
157
+ selectors: string[],
158
+ timeout = 5000,
159
+ ): Promise<boolean> {
160
+ const deadline = Date.now() + timeout;
161
+ while (true) {
162
+ for (const selector of selectors) {
163
+ const locator = page.locator(selector).first();
164
+ if (await locator.isVisible().catch(() => false)) {
165
+ await locator.click({ force: true });
166
+ return true;
167
+ }
168
+ }
169
+ if (Date.now() >= deadline) break;
170
+ await page.waitForTimeout(250).catch(() => {});
171
+ }
172
+ return false;
173
+ }
174
+
175
+ async function clickFirstEnabled(
176
+ page: Scope,
177
+ selectors: string[],
178
+ timeout = 30000,
179
+ ): Promise<boolean> {
180
+ const deadline = Date.now() + timeout;
181
+ while (Date.now() < deadline) {
182
+ for (const selector of selectors) {
183
+ const locator = page.locator(selector).first();
184
+ const visible = await locator.isVisible().catch(() => false);
185
+ if (!visible) continue;
186
+ const disabled = await locator
187
+ .evaluate((element: Element) => {
188
+ const button = element as HTMLButtonElement;
189
+ return Boolean(
190
+ button.disabled ||
191
+ element.getAttribute('disabled') !== null ||
192
+ element.classList.contains('disabled') ||
193
+ element.getAttribute('aria-disabled') === 'true',
194
+ );
195
+ })
196
+ .catch(() => false);
197
+ if (!disabled) {
198
+ await locator.click({ force: true });
199
+ return true;
200
+ }
201
+ }
202
+ await page.waitForTimeout(750);
203
+ }
204
+ return false;
205
+ }
206
+
207
+ /**
208
+ * Navigate to the legacy Turnitin assignment dashboard.
209
+ *
210
+ * After logging into the legacy student account Turnitin may redirect the user
211
+ * directly to the assignment dashboard (common for accounts enrolled in a
212
+ * single class with a single assignment). We detect this and skip navigation.
213
+ *
214
+ * Three recovery paths (in order):
215
+ * 1. Already on assignment dashboard → skip entirely.
216
+ * 2. On the class / assignment-list page → click the assignment Open button.
217
+ * 3. On the home page → full class → assignment navigation.
218
+ */
219
+ async function navigateLegacyAssignment(
220
+ page: Page,
221
+ classTitle: string,
222
+ assignmentTitle: string | null | undefined,
223
+ onEvent?: EngineEvent,
224
+ ): Promise<void> {
225
+ // Allow the post-login redirect to settle.
226
+ await page.waitForTimeout(2500);
227
+
228
+ // ── Case 1: Already on the assignment dashboard ─────────────────────────
229
+ const onDashboard = await visible(
230
+ page,
231
+ [
232
+ '#dashboard-table',
233
+ '.empty-assignment.student',
234
+ 'button.paper-upload[data-px="uploadSubmissionClicked"]',
235
+ 'button.paper-upload-modal',
236
+ '.student-submission-button',
237
+ ].join(', '),
238
+ 5000,
239
+ );
240
+ if (onDashboard) {
241
+ await emit(
242
+ onEvent,
243
+ 'info',
244
+ 'navigate',
245
+ 'Legacy account landed on assignment dashboard after login; skipping class navigation',
246
+ );
247
+ return;
248
+ }
249
+
250
+ // ── Case 2: On the class / assignment-list page ─────────────────────────
251
+ if (assignmentTitle) {
252
+ const assignmentRow = page
253
+ .locator('tr.assignment-row')
254
+ .filter({ hasText: assignmentTitle })
255
+ .locator('a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")')
256
+ .first();
257
+ const onAssignmentList = await assignmentRow
258
+ .waitFor({ state: 'visible', timeout: 3000 })
259
+ .then(() => true)
260
+ .catch(() => false);
261
+ if (onAssignmentList) {
262
+ await emit(onEvent, 'info', 'navigate', 'On class assignment list; clicking assignment Open button', {
263
+ assignmentTitle,
264
+ });
265
+ await Promise.all([
266
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => null),
267
+ assignmentRow.click({ force: true }),
268
+ ]);
269
+ await page.waitForTimeout(4000);
270
+ await acceptEulaEverywhere(page);
271
+ return;
272
+ }
273
+ }
274
+
275
+ // ── Case 3: Full home-page → class → assignment navigation ───────────────
276
+ await navigateToAssignment(page, classTitle ?? '', assignmentTitle);
277
+ }
278
+
279
+ async function getLegacySubmissionState(page: Page): Promise<{
280
+ hasExistingSubmission: boolean;
281
+ hasResubmitAction: boolean;
282
+ hasInitialUpload: boolean;
283
+ }> {
284
+ // Wait a moment for any dynamic content to render before checking state
285
+ await page.waitForTimeout(1500);
286
+
287
+ // Broad check for existing submission history table or similarity link
288
+ const hasExistingSubmission =
289
+ (await visible(page, '#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]', 6000)) ||
290
+ (await page
291
+ .locator('#dashboard-table, table')
292
+ .filter({ hasText: /Assignment Submissions|Similarity Score/i })
293
+ .first()
294
+ .waitFor({ state: 'visible', timeout: 3000 })
295
+ .then(() => true)
296
+ .catch(() => false)) ||
297
+ // Also check for resubmit action or submission rows that indicate a prior submission exists
298
+ (await visible(page, 'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper")', 3000));
299
+
300
+ const hasResubmitAction = await visible(
301
+ page,
302
+ 'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper"), .dropdown-toggle + .dropdown-menu a:has-text("Resubmit")',
303
+ 3000,
304
+ );
305
+
306
+ const hasInitialUpload = await visible(
307
+ page,
308
+ 'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission"), a.paper-upload:has-text("Upload Submission")',
309
+ 3000,
310
+ );
311
+
312
+ return { hasExistingSubmission, hasResubmitAction, hasInitialUpload };
313
+ }
314
+
315
+ async function openLegacyUploadModal(
316
+ page: Page,
317
+ mode: 'upload' | 'resubmit',
318
+ onEvent?: EngineEvent,
319
+ ): Promise<{ effectiveMode: 'upload' | 'resubmit'; scope: Scope }> {
320
+ const state = await getLegacySubmissionState(page);
321
+ let effectiveMode = mode;
322
+
323
+ if (mode === 'upload' && (state.hasExistingSubmission || state.hasResubmitAction)) {
324
+ effectiveMode = 'resubmit';
325
+ await emit(onEvent, 'warning', 'mode_switch', 'Existing legacy submission detected; switching to resubmit mode', {
326
+ requestedMode: mode,
327
+ effectiveMode,
328
+ });
329
+ } else if (mode === 'resubmit' && !state.hasResubmitAction && state.hasInitialUpload) {
330
+ effectiveMode = 'upload';
331
+ await emit(onEvent, 'warning', 'mode_switch', 'No legacy submission detected; switching to first submission mode', {
332
+ requestedMode: mode,
333
+ effectiveMode,
334
+ });
335
+ }
336
+
337
+ if (effectiveMode === 'resubmit') {
338
+ let opened = await clickFirstVisible(page, [
339
+ 'button.paper-upload-modal[title*="Resubmit"]',
340
+ 'a.paper-upload-modal:has-text("Resubmit paper")',
341
+ 'button:has-text("Resubmit paper")',
342
+ 'a:has-text("Resubmit paper")',
343
+ ], 5000);
344
+
345
+ if (!opened) {
346
+ // Try opening dropdown menus that might contain the resubmit option
347
+ await clickFirstVisible(page, [
348
+ '#dashboard-table button.dropdown-toggle',
349
+ '#dashboard-table .dropdown-toggle',
350
+ 'button.dropdown-toggle',
351
+ '.btn-group .dropdown-toggle',
352
+ ], 3000);
353
+ await page.waitForTimeout(800);
354
+ const openedFromMenu = await clickFirstVisible(page, [
355
+ 'a.paper-upload-modal:has-text("Resubmit paper")',
356
+ 'button:has-text("Resubmit paper")',
357
+ 'a:has-text("Resubmit paper")',
358
+ '.dropdown-menu a:has-text("Resubmit")',
359
+ ], 5000);
360
+ if (!openedFromMenu) {
361
+ // If no resubmit found but there's an Upload Submission button, the
362
+ // account may not have a prior submission — fall back to initial upload.
363
+ const hasInitialUpload = await visible(page,
364
+ 'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission")',
365
+ 3000,
366
+ );
367
+ if (hasInitialUpload) {
368
+ effectiveMode = 'upload';
369
+ await emit(onEvent, 'warning', 'mode_switch',
370
+ 'Legacy resubmit action not found but Upload Submission button is present; falling back to initial upload', {});
371
+ } else {
372
+ throw new Error('Legacy resubmit action was not found and no Upload Submission fallback is available.');
373
+ }
374
+ } else {
375
+ opened = true;
376
+ }
377
+ }
378
+
379
+ if (effectiveMode === 'resubmit') {
380
+ // After clicking resubmit, a confirmation dialog may appear
381
+ await page.waitForTimeout(800);
382
+ const confirmed = await clickFirstVisible(page, [
383
+ 'button.paper-upload[id^="upload_type-"]:has-text("Confirm")',
384
+ 'button.btn-primary.paper-upload:has-text("Confirm")',
385
+ 'button.paper-upload:has-text("Confirm")',
386
+ 'button:has-text("Confirm")',
387
+ ], 10000);
388
+ if (!confirmed) {
389
+ // Some Turnitin builds skip the confirm step — check if file input
390
+ // is already present without confirmation.
391
+ const fileInputPresent = await visible(
392
+ page,
393
+ 'input[data-test="submission-file-select"], input#file, input[type="file"]',
394
+ 3000,
395
+ );
396
+ if (!fileInputPresent) {
397
+ throw new Error('Legacy resubmit confirmation button was not found.');
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ if (effectiveMode === 'upload') {
404
+ const opened = await clickFirstVisible(page, [
405
+ 'button.paper-upload[data-px="uploadSubmissionClicked"]',
406
+ 'button.paper-upload:has-text("Upload Submission")',
407
+ 'button:has-text("Upload Submission")',
408
+ 'a.paper-upload:has-text("Upload Submission")',
409
+ ], 10000);
410
+ if (!opened) {
411
+ throw new Error('Legacy Upload Submission button was not found.');
412
+ }
413
+ }
414
+
415
+ // Accept any EULA modals that might pop up after clicking submit/resubmit
416
+ await page.waitForTimeout(2000);
417
+ await acceptEulaEverywhere(page);
418
+
419
+ // Resolve the frame scope that contains the file input (could be page or iframe)
420
+ const fileInputSelector = 'input[data-test="submission-file-select"], input#file, input[type="file"]';
421
+ let scope: Scope | null = null;
422
+ let inputAttached = false;
423
+ let lastErrorMessage = 'Legacy upload file input did not appear.';
424
+
425
+ for (let attempt = 1; attempt <= 2; attempt++) {
426
+ scope =
427
+ (await findLegacyAssignmentScope(
428
+ page,
429
+ fileInputSelector,
430
+ attempt === 1 ? 25000 : 45000,
431
+ )) || page;
432
+
433
+ inputAttached = await scope
434
+ .locator(fileInputSelector)
435
+ .first()
436
+ .waitFor({ state: 'attached', timeout: attempt === 1 ? 25000 : 45000 })
437
+ .then(() => true)
438
+ .catch((error) => {
439
+ lastErrorMessage = error instanceof Error ? error.message : String(error);
440
+ return false;
441
+ });
442
+
443
+ if (inputAttached) break;
444
+
445
+ logger.warn('Legacy upload file input did not appear after opening modal; retrying upload dialog', {
446
+ attempt,
447
+ effectiveMode,
448
+ error: lastErrorMessage,
449
+ });
450
+ await page.keyboard.press('Escape').catch(() => {});
451
+ await page.waitForTimeout(1500);
452
+
453
+ if (effectiveMode === 'upload') {
454
+ await clickFirstVisible(page, [
455
+ 'button.paper-upload[data-px="uploadSubmissionClicked"]',
456
+ 'button.paper-upload:has-text("Upload Submission")',
457
+ 'button:has-text("Upload Submission")',
458
+ 'a.paper-upload:has-text("Upload Submission")',
459
+ ], 10000);
460
+ } else {
461
+ await clickFirstVisible(page, [
462
+ 'button.paper-upload-modal[title*="Resubmit"]',
463
+ 'a.paper-upload-modal:has-text("Resubmit paper")',
464
+ 'button:has-text("Resubmit paper")',
465
+ 'a:has-text("Resubmit paper")',
466
+ ], 10000);
467
+ }
468
+ await page.waitForTimeout(2500);
469
+ await acceptEulaEverywhere(page);
470
+ }
471
+
472
+ if (!inputAttached || !scope) {
473
+ throw new Error(lastErrorMessage);
474
+ }
475
+
476
+ return { effectiveMode, scope };
477
+ }
478
+
479
+ async function readLegacyReviewDetails(page: Scope): Promise<SubmissionDetails | null> {
480
+ const rows = await page
481
+ .evaluate(() => {
482
+ const pick = (selector: string) =>
483
+ document.querySelector(selector)?.textContent?.trim() || '';
484
+ return {
485
+ fileName: pick('dd[data-test="submission-review-title"]'),
486
+ fileSize: pick('dd[data-test="submission-review-filesize"]'),
487
+ wordCount: pick('dd[data-test="submission-review-wordcount"]'),
488
+ };
489
+ })
490
+ .catch(() => ({ fileName: '', fileSize: '', wordCount: '' }));
491
+
492
+ const details: SubmissionDetails = {};
493
+ if (rows.fileName) details.fileName = rows.fileName;
494
+ if (rows.fileSize) details.fileSize = parseFileSizeBytes(rows.fileSize);
495
+ if (rows.wordCount) details.wordCount = parseNumber(rows.wordCount);
496
+ return Object.keys(details).length > 0 ? details : null;
497
+ }
498
+
499
+ async function completeLegacyUpload(
500
+ scope: Scope,
501
+ inputFilePath: string,
502
+ inputFileName?: string,
503
+ ): Promise<SubmissionDetails | null> {
504
+ const page = 'page' in scope ? (scope as any).page() : (scope as Page);
505
+
506
+ const fileInput = scope
507
+ .locator('input[data-test="submission-file-select"], input#file, input[type="file"]')
508
+ .first();
509
+ await fileInput.setInputFiles(inputFilePath);
510
+ await scope.waitForTimeout(1500);
511
+
512
+ const titleInput = scope
513
+ .locator('input[data-test="submission-title"], input[name="title"], input#title')
514
+ .first();
515
+ if (inputFileName && await titleInput.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)) {
516
+ await titleInput.fill(path.parse(inputFileName).name).catch(() => {});
517
+ }
518
+
519
+ const reviewClicked = await clickFirstEnabled(scope, [
520
+ 'button[data-test="submission-file-submit"]',
521
+ '.upload-and-review-btn',
522
+ 'button:has-text("Upload and Review")',
523
+ 'button:has-text("Upload & Review")',
524
+ ], 30000);
525
+ if (!reviewClicked) {
526
+ throw new Error('Legacy Upload and Review button was not found after selecting the file.');
527
+ }
528
+
529
+ // Wait for the review/confirmation panel to appear.
530
+ // Some Turnitin legacy builds skip the review panel and go straight to the
531
+ // dashboard when the file is already processed. We accept both paths.
532
+ const submitLocator = scope
533
+ .locator([
534
+ 'button[data-test="submission-review-button-submit"]',
535
+ 'button[data-test*="confirm"]',
536
+ 'button[data-test*="submit"]',
537
+ 'button:has-text("Submit to Turnitin")',
538
+ 'button:has-text("Confirm")',
539
+ ].join(', '))
540
+ .first();
541
+
542
+ // Wait up to 150 s for the submit button to appear, indicating file upload is complete and ready for confirmation
543
+ try {
544
+ await submitLocator.waitFor({ state: 'visible', timeout: 150000 });
545
+ } catch (err) {
546
+ throw new Error('Legacy review panel (Submit to Turnitin / Confirm) did not appear within 150 seconds.');
547
+ }
548
+
549
+ const reviewDetails = await readLegacyReviewDetails(scope);
550
+
551
+ const submitClicked = await clickFirstVisible(scope, [
552
+ 'button[data-test="submission-review-button-submit"]',
553
+ 'button[data-test*="confirm"]',
554
+ 'button[data-test*="submit"]',
555
+ 'button:has-text("Submit to Turnitin")',
556
+ 'button:has-text("Confirm")',
557
+ ], 30000);
558
+ if (!submitClicked) {
559
+ throw new Error('Legacy Submit to Turnitin / Confirm button was not found.');
560
+ }
561
+
562
+ // Wait for the Close / Go to Assignment Dashboard button and click it
563
+ const closeClicked = await clickFirstVisible(scope, [
564
+ 'button[data-test="submission-complete-button-close"]',
565
+ 'button:has-text("Close")',
566
+ 'button:has-text("Go to Assignment Dashboard")',
567
+ ], 60000);
568
+
569
+ if (closeClicked) {
570
+ logger.info('Legacy submission completed, closed modal.');
571
+ } else {
572
+ logger.warn('Legacy Close/Go to Assignment Dashboard button not found, continuing...');
573
+ }
574
+
575
+ // Wait for submission to appear on dashboard (up to 3 minutes)
576
+ await page
577
+ .locator('#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]')
578
+ .first()
579
+ .waitFor({ state: 'visible', timeout: 180000 });
580
+
581
+ return reviewDetails;
582
+ }
583
+
584
+ async function waitForLegacySimilarity(page: Page): Promise<number | null> {
585
+ const startedAt = Date.now();
586
+ const timeoutMs = Math.max(config.similarityTimeoutMs || 180000, 120000);
587
+ const reloadIntervalMs = 30000; // Reload every 30 seconds
588
+ let lastReloadAt = startedAt;
589
+
590
+ while (Date.now() - startedAt < timeoutMs) {
591
+ // Check both specific similarity score cells and any visible percentage
592
+ const text = await page
593
+ .locator('td[data-title="Similarity Score"] a.similarity-open, td[data-title="Similarity Score"], a.similarity-open')
594
+ .first()
595
+ .textContent({ timeout: 5000 })
596
+ .catch(() => null);
597
+ const percent = parsePercent(text);
598
+ if (percent !== null) return percent;
599
+
600
+ // Also check if similarity column shows a number without %
601
+ const rawText = await page
602
+ .locator('td[data-title="Similarity Score"]')
603
+ .first()
604
+ .textContent({ timeout: 2000 })
605
+ .catch(() => null);
606
+ const rawPercent = parsePercent(rawText);
607
+ if (rawPercent !== null) return rawPercent;
608
+
609
+ // Reload the page periodically to fetch the updated status
610
+ if (Date.now() - lastReloadAt >= reloadIntervalMs) {
611
+ logger.info('Similarity score not ready yet, reloading legacy assignment dashboard...');
612
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
613
+ await page.waitForTimeout(5000);
614
+ lastReloadAt = Date.now();
615
+ } else {
616
+ await page.waitForTimeout(config.similarityPollMs || 5000);
617
+ }
618
+ }
619
+
620
+ return null;
621
+ }
622
+
623
+ /**
624
+ * Viewer URL patterns accepted for legacy carta reports.
625
+ * Turnitin serves the viewer from multiple subdomains depending on the region
626
+ * and account type — accept any of them.
627
+ */
628
+ const VIEWER_URL_PATTERNS = [
629
+ /ev\.turnitin\.com\/app\/carta/i,
630
+ /reports\.integrity\.turnitin\.com/i,
631
+ /submission-viewer/i,
632
+ ];
633
+
634
+ function isViewerUrl(url: string): boolean {
635
+ return VIEWER_URL_PATTERNS.some((re) => re.test(url));
636
+ }
637
+
638
+ async function openLegacyViewer(page: Page, context: BrowserContext): Promise<Page> {
639
+ const opener = page
640
+ .locator('td[data-title="Similarity Score"] a.similarity-open, a.default-open[data-paper-title], a.default-open')
641
+ .first();
642
+
643
+ await opener.waitFor({ state: 'visible', timeout: 30000 });
644
+ const newPagePromise = context.waitForEvent('page', { timeout: 25000 }).catch(() => null);
645
+ await opener.click({ force: true });
646
+ const newPage = await newPagePromise;
647
+ const viewerPage = newPage || page;
648
+
649
+ await viewerPage.waitForLoadState('domcontentloaded', { timeout: 60000 }).catch(() => {});
650
+ // The legacy carta viewer (ev.turnitin.com/app/carta) is a heavy JavaScript
651
+ // application that may take 10+ seconds to render sidebar controls.
652
+ // Wait for the URL to settle and the viewer content to appear.
653
+ await viewerPage.waitForTimeout(5000);
654
+
655
+ if (!isViewerUrl(viewerPage.url())) {
656
+ // Poll for up to 30 s for the viewer URL to settle
657
+ await viewerPage
658
+ .waitForURL((url) => isViewerUrl(url.toString()), { timeout: 30000 })
659
+ .catch(() => {});
660
+ }
661
+
662
+ if (!isViewerUrl(viewerPage.url())) {
663
+ throw new Error(`Legacy report viewer did not open. Current URL: ${viewerPage.url()}`);
664
+ }
665
+
666
+ // Wait for the carta viewer sidebar to render filter controls (up to 20 s).
667
+ await visible(
668
+ viewerPage,
669
+ '.apply-changes-button, .osi-score, .sidebar-paper-info-button, .exclude-quotes-checkbox',
670
+ 20000,
671
+ ).catch(() => {});
672
+
673
+ return viewerPage;
674
+ }
675
+
676
+ async function readLegacySubmissionDetails(page: Page): Promise<SubmissionDetails | null> {
677
+ await clickFirstVisible(page, [
678
+ '[data-px="EVSimReportSubmissionInformationClicked"]',
679
+ '.sidebar-paper-info-button',
680
+ '[title="Submission Information"]',
681
+ 'button:has-text("Submission Information")',
682
+ 'button:has-text("Details")',
683
+ ], 10000);
684
+ await page.waitForTimeout(1200);
685
+
686
+ const rows = await page
687
+ .evaluate(() => {
688
+ const wanted = [
689
+ 'Student ID',
690
+ 'Class Name',
691
+ 'Class ID',
692
+ 'Submission ID',
693
+ 'Submission Date',
694
+ 'Submission Count',
695
+ 'File Name',
696
+ 'File Extension',
697
+ 'File Size',
698
+ 'Character Count',
699
+ 'Char Count',
700
+ 'Word Count',
701
+ 'Page Count',
702
+ ];
703
+ const out: Array<{ term: string; value: string }> = [];
704
+ const all = Array.from(document.querySelectorAll('li, dd, div, span'));
705
+ const clean = (value: string | null | undefined) =>
706
+ String(value || '').replace(/\s+/g, ' ').trim();
707
+
708
+ for (const label of wanted) {
709
+ const labelNode = all.find((node) => clean(node.textContent) === label);
710
+ if (!labelNode) continue;
711
+ const container = labelNode.closest('li, dl, .submission-details-item, .paper-info-item') || labelNode.parentElement;
712
+ const explicitValue =
713
+ container?.querySelector('[role="definition"], .submission-details-value, dd, .value, .paper-info-value')?.textContent ||
714
+ labelNode.nextElementSibling?.textContent ||
715
+ '';
716
+ const value = clean(explicitValue);
717
+ if (value && value !== label) out.push({ term: label, value });
718
+ }
719
+ return out;
720
+ })
721
+ .catch(() => []);
722
+
723
+ const details: SubmissionDetails = {};
724
+ for (const row of rows) {
725
+ const key = normalizeLabel(row.term);
726
+ if (!key) continue;
727
+
728
+ if (
729
+ key === 'fileName' ||
730
+ key === 'fileExtension' ||
731
+ key === 'studentId' ||
732
+ key === 'className' ||
733
+ key === 'classId' ||
734
+ key === 'submissionId' ||
735
+ key === 'submissionDate'
736
+ ) {
737
+ details[key] = row.value;
738
+ } else if (key === 'fileSize') {
739
+ const parsed = parseFileSizeBytes(row.value);
740
+ if (parsed !== undefined) details.fileSize = parsed;
741
+ } else {
742
+ const parsed = parseNumber(row.value);
743
+ if (parsed !== undefined) details[key] = parsed;
744
+ }
745
+ }
746
+
747
+ await page.keyboard.press('Escape').catch(() => {});
748
+ await page.waitForTimeout(700).catch(() => {});
749
+ return Object.keys(details).length > 0 ? details : null;
750
+ }
751
+
752
+ async function openLegacyFilters(page: Page): Promise<boolean> {
753
+ // In the legacy carta viewer (ev.turnitin.com/app/carta) the filter controls
754
+ // are rendered directly in the sidebar — no button click is needed.
755
+ // Wait generously for the JavaScript to finish rendering.
756
+ const alreadyVisible = await legacyFilterPanelVisible(page, 15000);
757
+ if (alreadyVisible) return true;
758
+
759
+ // Some carta builds may require clicking a sidebar toggle; try common selectors.
760
+ const clicked = await clickFirstVisible(page, [
761
+ '[data-px="EVSimReportFiltersClicked"]',
762
+ '[title="Filters and Settings"]',
763
+ '[data-px*="Filter"]',
764
+ '.sidebar-filter-button',
765
+ '[title="Filters"]',
766
+ 'button:has-text("Filters")',
767
+ ], 5000) || await clickLegacyCartaElement(page, [
768
+ '[data-px="EVSimReportFiltersClicked"]',
769
+ '[title="Filters and Settings"]',
770
+ '[role="button"][title*="Filters"]',
771
+ '.tii-icon-funnel',
772
+ '.sc-segment-view',
773
+ ], ['filters and settings', 'filters'], 8000);
774
+
775
+ if (clicked) {
776
+ await resetLegacyViewerZoom(page);
777
+ await page.waitForTimeout(2500);
778
+ }
779
+
780
+ return await legacyFilterPanelVisible(page, 10000);
781
+ }
782
+
783
+ async function closeLegacyFilters(page: Page): Promise<void> {
784
+ await resetLegacyViewerZoom(page);
785
+ await clickLegacyCartaElement(page, [
786
+ '[title="Match Overview"]',
787
+ '[data-px*="MatchOverview"]',
788
+ '[data-px*="SimilarityReport"]',
789
+ '.tii-icon-match-overview',
790
+ '.sc-segment-view',
791
+ ], ['match overview', 'similarity report', 'similarity'], 5000).catch(() => false);
792
+ await page.keyboard.press('Escape').catch(() => {});
793
+ await page.waitForTimeout(1000).catch(() => {});
794
+ }
795
+
796
+ async function legacyFilterPanelVisible(page: Page, timeoutMs = 8000): Promise<boolean> {
797
+ const deadline = Date.now() + timeoutMs;
798
+ while (Date.now() < deadline) {
799
+ const found = await page.evaluate(() => {
800
+ const isVisible = (element: Element): boolean => {
801
+ if (!(element instanceof HTMLElement)) return false;
802
+ const style = window.getComputedStyle(element);
803
+ const rect = element.getBoundingClientRect();
804
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
805
+ };
806
+
807
+ const directControls = Array.from(
808
+ document.querySelectorAll(
809
+ '.apply-changes-button, .exclude-quotes-checkbox, .exclude-biblio-checkbox, .small-matches-radio-group, .filter-inputs input',
810
+ ),
811
+ );
812
+ if (directControls.some(isVisible)) return true;
813
+
814
+ const clean = (value: string | null | undefined) =>
815
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
816
+ const textFor = (element: Element): string => {
817
+ const labelledBy = element.getAttribute('aria-labelledby');
818
+ const labelText = labelledBy
819
+ ?.split(/\s+/)
820
+ .map((id) => document.getElementById(id)?.textContent || '')
821
+ .join(' ');
822
+ return clean([
823
+ element.textContent || '',
824
+ element.getAttribute('aria-label') || '',
825
+ element.getAttribute('title') || '',
826
+ labelText || '',
827
+ ].join(' '));
828
+ };
829
+
830
+ const controls = Array.from(
831
+ document.querySelectorAll<HTMLElement>('[role="checkbox"], [role="radio"][index], .sc-checkbox-control, .sc-radio-button'),
832
+ ).filter(isVisible);
833
+ const hasQuotes = controls.some((element) => textFor(element).includes('exclude quotes'));
834
+ const hasBibliography = controls.some((element) => textFor(element).includes('exclude bibliography'));
835
+ const hasSmallMatches = controls.some((element) =>
836
+ textFor(element).includes('words') ||
837
+ textFor(element).includes('%') ||
838
+ textFor(element).includes("don't exclude by size"),
839
+ );
840
+
841
+ return hasQuotes || hasBibliography || hasSmallMatches;
842
+ }).catch(() => false);
843
+ if (found) return true;
844
+ await page.waitForTimeout(400).catch(() => {});
845
+ }
846
+ return false;
847
+ }
848
+
849
+ async function resetLegacyViewerZoom(page: Page): Promise<void> {
850
+ await page.keyboard.press('Control+0').catch(() => {});
851
+ await page
852
+ .evaluate(() => {
853
+ if (document.activeElement instanceof HTMLElement) {
854
+ document.activeElement.blur();
855
+ }
856
+ document.documentElement.style.zoom = '1';
857
+ if (document.body) document.body.style.zoom = '1';
858
+ window.scrollTo(0, 0);
859
+ })
860
+ .catch(() => {});
861
+ }
862
+
863
+ async function setLegacyCheckboxByText(
864
+ page: Page,
865
+ label: string,
866
+ enabled: boolean,
867
+ ): Promise<boolean> {
868
+ return page.evaluate(
869
+ ({ labelText, desired }) => {
870
+ const clean = (value: string | null | undefined) =>
871
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
872
+ const desiredLabel = clean(labelText);
873
+
874
+ const textFor = (element: Element): string => {
875
+ const labelledBy = element.getAttribute('aria-labelledby');
876
+ const labelText = labelledBy
877
+ ?.split(/\s+/)
878
+ .map((id) => document.getElementById(id)?.textContent || '')
879
+ .join(' ');
880
+ return clean([
881
+ element.textContent || '',
882
+ element.getAttribute('aria-label') || '',
883
+ element.getAttribute('title') || '',
884
+ labelText || '',
885
+ ].join(' '));
886
+ };
887
+
888
+ const findCheckbox = (): HTMLElement | HTMLInputElement | null => {
889
+ const direct = desiredLabel.includes('quotes')
890
+ ? document.querySelector('.exclude-quotes-checkbox')
891
+ : desiredLabel.includes('bibliography')
892
+ ? document.querySelector('.exclude-biblio-checkbox')
893
+ : null;
894
+ if (direct) return direct as HTMLElement;
895
+
896
+ const candidates = Array.from(
897
+ document.querySelectorAll<HTMLElement | HTMLInputElement>(
898
+ '[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]',
899
+ ),
900
+ );
901
+ const matched = candidates.find((element) => textFor(element).includes(desiredLabel));
902
+ if (matched) return matched;
903
+
904
+ const labelCandidates = Array.from(document.querySelectorAll('label, span, div'));
905
+ const labelNode = labelCandidates.find((node) => clean(node.textContent).includes(desiredLabel));
906
+ if (!labelNode) return null;
907
+ const container = labelNode.closest('fieldset, li, label, div, tr') || labelNode.parentElement;
908
+ return (
909
+ container?.querySelector('input[type="checkbox"], [role="checkbox"], .sc-checkbox-control') ||
910
+ labelNode.closest('[role="checkbox"], .sc-checkbox-control')
911
+ ) as HTMLElement | HTMLInputElement | null;
912
+ };
913
+
914
+ const checkbox = findCheckbox();
915
+ if (!checkbox) return false;
916
+
917
+ const current =
918
+ checkbox instanceof HTMLInputElement
919
+ ? checkbox.checked
920
+ : checkbox.getAttribute('aria-checked') === 'true' ||
921
+ checkbox.classList.contains('sel') ||
922
+ checkbox.classList.contains('selected') ||
923
+ checkbox.classList.contains('checked');
924
+
925
+ if (current !== desired) checkbox.click();
926
+ return true;
927
+ },
928
+ { labelText: label, desired: enabled },
929
+ ).catch(() => false);
930
+ }
931
+
932
+ async function setLegacySmallMatches(page: Page, filters: FilterOptions): Promise<void> {
933
+ const mode = filters.smallMatchMode || 'words';
934
+ const enabled = Boolean(filters.excludeSmallMatches) && mode !== 'off';
935
+
936
+ if (!enabled) {
937
+ await clickLegacySmallMatchRadio(page, '2');
938
+ await resetLegacyViewerZoom(page);
939
+ return;
940
+ }
941
+
942
+ await clickLegacySmallMatchRadio(page, mode === 'percent' ? '1' : '0');
943
+ await page.waitForTimeout(500);
944
+
945
+ const rawThreshold = Number(filters.smallMatchThreshold) || 8;
946
+ const max = mode === 'percent' ? 100 : 40;
947
+ const threshold = String(Math.min(max, Math.max(1, Math.round(rawThreshold))));
948
+
949
+ const targetSelector = await page.evaluate((desiredMode) => {
950
+ const inputs = Array.from(
951
+ document.querySelectorAll<HTMLInputElement>(
952
+ '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[aria-label*="source"], input[aria-label*="match"]',
953
+ ),
954
+ )
955
+ .filter((input) => {
956
+ const style = window.getComputedStyle(input);
957
+ const rect = input.getBoundingClientRect();
958
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
959
+ })
960
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
961
+
962
+ const input = inputs[desiredMode === 'percent' ? 1 : 0] || inputs[0];
963
+ if (!input) return null;
964
+ document
965
+ .querySelectorAll('[data-relv-small-match-target]')
966
+ .forEach((element) => element.removeAttribute('data-relv-small-match-target'));
967
+ input.setAttribute('data-relv-small-match-target', desiredMode);
968
+ return `[data-relv-small-match-target="${desiredMode}"]`;
969
+ }, mode).catch(() => null);
970
+
971
+ let keyboardSet = false;
972
+ let inputName: string | null = null;
973
+ if (targetSelector) {
974
+ const target = page.locator(targetSelector).first();
975
+ inputName = await target
976
+ .evaluate((input: HTMLInputElement) => input.name || input.id || '')
977
+ .catch(() => null);
978
+ await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
979
+ await target.click({ force: true, clickCount: 3, timeout: 3000 }).catch(() => {});
980
+ await page.keyboard.press('Control+A').catch(() => {});
981
+ await page.keyboard.press('Backspace').catch(() => {});
982
+ await page.keyboard.type(threshold, { delay: 45 }).catch(() => {});
983
+ await page.keyboard.press('Tab').catch(() => {});
984
+ await page.waitForTimeout(300);
985
+ const valueAfterKeyboard = await target
986
+ .evaluate((input: HTMLInputElement) => input.value || input.getAttribute('value') || '')
987
+ .catch(() => '');
988
+ keyboardSet = valueAfterKeyboard.replace(/[^\d]/g, '') === threshold;
989
+ }
990
+
991
+ let setInDom = false;
992
+ if (!keyboardSet) {
993
+ setInDom = await page.evaluate(
994
+ ({ desiredMode, desiredValue }) => {
995
+ const isVisible = (element: Element): boolean => {
996
+ if (!(element instanceof HTMLElement)) return false;
997
+ const style = window.getComputedStyle(element);
998
+ const rect = element.getBoundingClientRect();
999
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
1000
+ };
1001
+
1002
+ const inputs = Array.from(
1003
+ document.querySelectorAll<HTMLInputElement>(
1004
+ '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]',
1005
+ ),
1006
+ ).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
1007
+ if (inputs.length === 0) return false;
1008
+
1009
+ const index = desiredMode === 'percent' ? 1 : 0;
1010
+ const visibleInputs = inputs.filter(isVisible);
1011
+ const input = inputs[index] || visibleInputs[0] || inputs[0];
1012
+ if (!input) return false;
1013
+
1014
+ input.focus();
1015
+ input.select?.();
1016
+ const nativeSetter = Object.getOwnPropertyDescriptor(
1017
+ window.HTMLInputElement.prototype,
1018
+ 'value',
1019
+ )?.set;
1020
+ nativeSetter?.call(input, desiredValue);
1021
+ input.value = desiredValue;
1022
+ input.setAttribute('value', desiredValue);
1023
+ input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Backspace' }));
1024
+ input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Backspace' }));
1025
+ for (const char of desiredValue) {
1026
+ input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: char }));
1027
+ input.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, key: char }));
1028
+ input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: char }));
1029
+ }
1030
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1031
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1032
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
1033
+ input.closest('.filter-inputs')?.dispatchEvent(new Event('change', { bubbles: true }));
1034
+ return true;
1035
+ },
1036
+ { desiredMode: mode, desiredValue: threshold },
1037
+ ).catch(() => false);
1038
+ }
1039
+
1040
+ await resetLegacyViewerZoom(page);
1041
+
1042
+ if (!keyboardSet && !setInDom) {
1043
+ logger.warn('Legacy small matches threshold input was not visible', { mode, threshold });
1044
+ } else {
1045
+ logger.info('Legacy small matches threshold set', {
1046
+ mode,
1047
+ threshold,
1048
+ input: inputName,
1049
+ usedKeyboard: keyboardSet,
1050
+ usedDomFallback: setInDom,
1051
+ });
1052
+ }
1053
+ }
1054
+
1055
+ async function clickLegacySmallMatchRadio(page: Page, index: '0' | '1' | '2'): Promise<boolean> {
1056
+ return page.evaluate((radioIndex) => {
1057
+ const clean = (value: string | null | undefined) =>
1058
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
1059
+ const dispatchClick = (element: HTMLElement): void => {
1060
+ element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
1061
+ element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
1062
+ element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
1063
+ element.click();
1064
+ element.dispatchEvent(new Event('change', { bubbles: true }));
1065
+ };
1066
+
1067
+ const direct = document.querySelector<HTMLElement>(
1068
+ `.small-matches-radio-group [role="radio"][index="${radioIndex}"], [role="radio"][index="${radioIndex}"], .radio-${radioIndex}`,
1069
+ );
1070
+ if (direct) {
1071
+ dispatchClick(direct);
1072
+ return true;
1073
+ }
1074
+
1075
+ const label =
1076
+ radioIndex === '0'
1077
+ ? 'words'
1078
+ : radioIndex === '1'
1079
+ ? '%'
1080
+ : "don't exclude by size";
1081
+ const candidate = Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find(
1082
+ (element) => clean(element.textContent).includes(label),
1083
+ );
1084
+ if (!candidate) return false;
1085
+ dispatchClick(candidate);
1086
+ return true;
1087
+ }, index).catch(() => false);
1088
+ }
1089
+
1090
+ type LegacyFilterState = {
1091
+ excludeQuotes: boolean | null;
1092
+ excludeBibliography: boolean | null;
1093
+ smallMatchMode: 'words' | 'percent' | 'off' | null;
1094
+ wordsThreshold: number | null;
1095
+ percentThreshold: number | null;
1096
+ };
1097
+
1098
+ function getDesiredLegacySmallMatch(filters: FilterOptions): {
1099
+ mode: 'words' | 'percent' | 'off';
1100
+ threshold: number | null;
1101
+ } {
1102
+ if (!filters.excludeSmallMatches || filters.smallMatchMode === 'off') {
1103
+ return { mode: 'off', threshold: null };
1104
+ }
1105
+
1106
+ const mode = filters.smallMatchMode === 'percent' ? 'percent' : 'words';
1107
+ const max = mode === 'percent' ? 100 : 40;
1108
+ const threshold = Math.min(
1109
+ max,
1110
+ Math.max(1, Math.round(Number(filters.smallMatchThreshold) || 8)),
1111
+ );
1112
+
1113
+ return { mode, threshold };
1114
+ }
1115
+
1116
+ async function readLegacyFilterState(page: Page): Promise<LegacyFilterState> {
1117
+ return page.evaluate(() => {
1118
+ const clean = (value: string | null | undefined) =>
1119
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
1120
+
1121
+ const textFor = (element: Element): string => {
1122
+ const labelledBy = element.getAttribute('aria-labelledby');
1123
+ const labelText = labelledBy
1124
+ ?.split(/\s+/)
1125
+ .map((id) => document.getElementById(id)?.textContent || '')
1126
+ .join(' ');
1127
+ return clean([
1128
+ element.textContent || '',
1129
+ element.getAttribute('aria-label') || '',
1130
+ element.getAttribute('title') || '',
1131
+ labelText || '',
1132
+ ].join(' '));
1133
+ };
1134
+
1135
+ const isVisible = (element: Element): boolean => {
1136
+ if (!(element instanceof HTMLElement)) return false;
1137
+ const style = window.getComputedStyle(element);
1138
+ const rect = element.getBoundingClientRect();
1139
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
1140
+ };
1141
+
1142
+ const stateForToggle = (element: HTMLElement | HTMLInputElement): boolean | null => {
1143
+ if (element instanceof HTMLInputElement) return element.checked;
1144
+ const input = element.querySelector('input[type="checkbox"], input[type="radio"]') as HTMLInputElement | null;
1145
+ if (input) return input.checked;
1146
+ const aria = element.getAttribute('aria-checked');
1147
+ if (aria === 'true') return true;
1148
+ if (aria === 'false') return false;
1149
+ if (
1150
+ element.classList.contains('sel') ||
1151
+ element.classList.contains('selected') ||
1152
+ element.classList.contains('checked')
1153
+ ) {
1154
+ return true;
1155
+ }
1156
+ return null;
1157
+ };
1158
+
1159
+ const findByLabel = (
1160
+ directSelector: string,
1161
+ roleSelector: string,
1162
+ label: string,
1163
+ ): HTMLElement | HTMLInputElement | null => {
1164
+ const direct = document.querySelector(directSelector) as HTMLElement | HTMLInputElement | null;
1165
+ if (direct) return direct;
1166
+
1167
+ const desiredLabel = clean(label);
1168
+ const candidates = Array.from(
1169
+ document.querySelectorAll<HTMLElement | HTMLInputElement>(roleSelector),
1170
+ );
1171
+ const matched = candidates.find((element) => textFor(element).includes(desiredLabel));
1172
+ if (matched) return matched;
1173
+
1174
+ const labels = Array.from(document.querySelectorAll('label, span, div'));
1175
+ const labelNode = labels.find((node) => clean(node.textContent).includes(desiredLabel));
1176
+ const container = labelNode?.closest('fieldset, li, label, div, tr') || labelNode?.parentElement;
1177
+ return (
1178
+ container?.querySelector(roleSelector) ||
1179
+ labelNode?.closest(roleSelector)
1180
+ ) as HTMLElement | HTMLInputElement | null;
1181
+ };
1182
+
1183
+ const readChecked = (directSelector: string, label: string): boolean | null => {
1184
+ const element = findByLabel(
1185
+ directSelector,
1186
+ '[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]',
1187
+ label,
1188
+ );
1189
+ if (!element) return null;
1190
+ return stateForToggle(element);
1191
+ };
1192
+
1193
+ const readRadio = (index: string, label: string): boolean => {
1194
+ const element = (
1195
+ document.querySelector(
1196
+ `.small-matches-radio-group [role="radio"][index="${index}"], [role="radio"][index="${index}"], .radio-${index}`,
1197
+ ) ||
1198
+ Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find(
1199
+ (candidate) => textFor(candidate).includes(clean(label)),
1200
+ )
1201
+ ) as HTMLElement | HTMLInputElement | null;
1202
+ if (!element) return false;
1203
+ return stateForToggle(element) === true;
1204
+ };
1205
+
1206
+ const thresholdInputs = (Array.from(
1207
+ document.querySelectorAll(
1208
+ '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]',
1209
+ ),
1210
+ ) as HTMLInputElement[]).sort(
1211
+ (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top,
1212
+ );
1213
+
1214
+ const parseThresholdInput = (input: HTMLInputElement | undefined): number | null => {
1215
+ if (!input) return null;
1216
+ const raw = String(input.value || '').replace(/[^\d]/g, '');
1217
+ if (!raw) return null;
1218
+ const numeric = Number(raw);
1219
+ return Number.isFinite(numeric) ? numeric : null;
1220
+ };
1221
+
1222
+ const parseInput = (index: number): number | null => parseThresholdInput(thresholdInputs[index]);
1223
+
1224
+ const visibleThreshold = parseThresholdInput(
1225
+ thresholdInputs.find((input) => isVisible(input)),
1226
+ );
1227
+
1228
+ let smallMatchMode: LegacyFilterState['smallMatchMode'] = null;
1229
+ if (readRadio('0', 'words')) smallMatchMode = 'words';
1230
+ else if (readRadio('1', '%')) smallMatchMode = 'percent';
1231
+ else if (readRadio('2', "don't exclude by size")) smallMatchMode = 'off';
1232
+
1233
+ let wordsThreshold = parseInput(0);
1234
+ let percentThreshold = parseInput(1);
1235
+ if (smallMatchMode === 'words' && wordsThreshold === null) {
1236
+ wordsThreshold = visibleThreshold;
1237
+ }
1238
+ if (smallMatchMode === 'percent' && percentThreshold === null) {
1239
+ percentThreshold = visibleThreshold;
1240
+ }
1241
+
1242
+ return {
1243
+ excludeQuotes: readChecked('.exclude-quotes-checkbox', 'Exclude Quotes'),
1244
+ excludeBibliography: readChecked('.exclude-biblio-checkbox', 'Exclude Bibliography'),
1245
+ smallMatchMode,
1246
+ wordsThreshold,
1247
+ percentThreshold,
1248
+ };
1249
+ });
1250
+ }
1251
+
1252
+ async function verifyLegacyFilterStates(
1253
+ page: Page,
1254
+ filters: FilterOptions,
1255
+ ): Promise<{ state: LegacyFilterState; mismatches: string[] }> {
1256
+ const desiredSmallMatch = getDesiredLegacySmallMatch(filters);
1257
+ const state = await readLegacyFilterState(page);
1258
+ const mismatches: string[] = [];
1259
+
1260
+ const expectedQuotes = Boolean(filters.excludeQuotes);
1261
+ const expectedBibliography = Boolean(filters.excludeBibliography);
1262
+
1263
+ if (expectedQuotes ? state.excludeQuotes !== true : state.excludeQuotes === true) {
1264
+ mismatches.push(`Exclude Quotes expected ${expectedQuotes}, got ${state.excludeQuotes}`);
1265
+ }
1266
+ if (expectedBibliography ? state.excludeBibliography !== true : state.excludeBibliography === true) {
1267
+ mismatches.push(
1268
+ `Exclude Bibliography expected ${expectedBibliography}, got ${state.excludeBibliography}`,
1269
+ );
1270
+ }
1271
+ if (
1272
+ desiredSmallMatch.mode !== 'off' &&
1273
+ state.smallMatchMode !== desiredSmallMatch.mode
1274
+ ) {
1275
+ mismatches.push(
1276
+ `Small matches mode expected ${desiredSmallMatch.mode}, got ${state.smallMatchMode}`,
1277
+ );
1278
+ }
1279
+ if (
1280
+ desiredSmallMatch.mode === 'off' &&
1281
+ (state.smallMatchMode === 'words' || state.smallMatchMode === 'percent')
1282
+ ) {
1283
+ mismatches.push(
1284
+ `Small matches mode expected off, got ${state.smallMatchMode}`,
1285
+ );
1286
+ }
1287
+ if (desiredSmallMatch.mode === 'words' && state.wordsThreshold !== desiredSmallMatch.threshold) {
1288
+ mismatches.push(
1289
+ `Small matches words threshold expected ${desiredSmallMatch.threshold}, got ${state.wordsThreshold}`,
1290
+ );
1291
+ }
1292
+ if (desiredSmallMatch.mode === 'percent' && state.percentThreshold !== desiredSmallMatch.threshold) {
1293
+ mismatches.push(
1294
+ `Small matches percent threshold expected ${desiredSmallMatch.threshold}, got ${state.percentThreshold}`,
1295
+ );
1296
+ }
1297
+
1298
+ return { state, mismatches };
1299
+ }
1300
+
1301
+ async function applyLegacyFilters(page: Page, filters: FilterOptions): Promise<void> {
1302
+ const ownerPage = page;
1303
+ const activeFilters = hasActiveFilters(filters);
1304
+
1305
+ for (let attempt = 1; attempt <= 3; attempt++) {
1306
+ const opened = await openLegacyFilters(page);
1307
+ if (!opened) {
1308
+ if (attempt < 3) {
1309
+ // Reload and give the carta viewer extra time to re-render its sidebar.
1310
+ await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
1311
+ await ownerPage.waitForTimeout(10000);
1312
+ continue;
1313
+ }
1314
+ if (activeFilters) {
1315
+ throw new Error('Legacy filter panel could not be opened after 3 viewer refresh attempts.');
1316
+ }
1317
+ return;
1318
+ }
1319
+
1320
+ const initialVerification = await verifyLegacyFilterStates(page, filters).catch(() => null);
1321
+ const alreadyApplied =
1322
+ initialVerification !== null && initialVerification.mismatches.length === 0;
1323
+
1324
+ if (alreadyApplied) {
1325
+ logger.info('Legacy filters already matched requested state; skipping Apply Changes', {
1326
+ state: initialVerification.state,
1327
+ filters,
1328
+ });
1329
+ await closeLegacyFilters(page);
1330
+ return;
1331
+ }
1332
+
1333
+ await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes));
1334
+ await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography));
1335
+ await setLegacySmallMatches(page, filters);
1336
+ await page.waitForTimeout(700);
1337
+
1338
+ let verification = await verifyLegacyFilterStates(page, filters);
1339
+ if (verification.mismatches.length > 0) {
1340
+ logger.warn('Legacy filter verification failed after first set; retrying filter state update', {
1341
+ mismatches: verification.mismatches,
1342
+ state: verification.state,
1343
+ filters,
1344
+ });
1345
+
1346
+ await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes));
1347
+ await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography));
1348
+ await setLegacySmallMatches(page, filters);
1349
+ await page.waitForTimeout(700);
1350
+ verification = await verifyLegacyFilterStates(page, filters);
1351
+ }
1352
+
1353
+ if (verification.mismatches.length > 0) {
1354
+ throw new Error(
1355
+ `Legacy filters could not be verified before applying changes: ${verification.mismatches.join('; ')}`,
1356
+ );
1357
+ }
1358
+
1359
+ logger.info('Legacy filter verification passed', {
1360
+ state: verification.state,
1361
+ filters,
1362
+ });
1363
+
1364
+ await resetLegacyViewerZoom(page);
1365
+ const appliedByDom = await clickLegacyApplyChanges(page);
1366
+ if (appliedByDom) {
1367
+ await page.waitForTimeout(3500);
1368
+ return;
1369
+ }
1370
+
1371
+ if (!activeFilters) {
1372
+ await closeLegacyFilters(page);
1373
+ return;
1374
+ }
1375
+ if (attempt < 3) {
1376
+ await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
1377
+ await ownerPage.waitForTimeout(5000);
1378
+ }
1379
+ }
1380
+
1381
+ throw new Error('Legacy Apply Changes button was not visible after 3 viewer refresh attempts.');
1382
+ }
1383
+
1384
+ async function clickLegacyApplyChanges(page: Page): Promise<boolean> {
1385
+ const deadline = Date.now() + 12000;
1386
+ let foundDisabled = false;
1387
+
1388
+ while (Date.now() < deadline) {
1389
+ const result = await page.evaluate(() => {
1390
+ const clean = (value: string | null | undefined) =>
1391
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
1392
+ const textFor = (element: Element): string => {
1393
+ const labelledBy = element.getAttribute('aria-labelledby');
1394
+ const labelText = labelledBy
1395
+ ?.split(/\s+/)
1396
+ .map((id) => document.getElementById(id)?.textContent || '')
1397
+ .join(' ');
1398
+ return clean([
1399
+ element.textContent || '',
1400
+ element.getAttribute('aria-label') || '',
1401
+ element.getAttribute('title') || '',
1402
+ labelText || '',
1403
+ ].join(' '));
1404
+ };
1405
+
1406
+ const button = (
1407
+ document.querySelector<HTMLElement>('.apply-changes-button') ||
1408
+ Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) =>
1409
+ textFor(element).includes('apply changes'),
1410
+ )
1411
+ ) as HTMLElement | null;
1412
+
1413
+ if (!button) return { found: false, disabled: false, clicked: false };
1414
+
1415
+ const disabled =
1416
+ button.classList.contains('disabled') ||
1417
+ button.getAttribute('aria-disabled') === 'true' ||
1418
+ button.hasAttribute('disabled') ||
1419
+ (button as HTMLButtonElement).disabled === true;
1420
+
1421
+ if (disabled) return { found: true, disabled: true, clicked: false };
1422
+
1423
+ button.scrollIntoView({ block: 'center', inline: 'center' });
1424
+ button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
1425
+ button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
1426
+ button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
1427
+ button.click();
1428
+ return { found: true, disabled: false, clicked: true };
1429
+ }).catch(() => ({ found: false, disabled: false, clicked: false }));
1430
+
1431
+ if (result.clicked) return true;
1432
+ if (result.found && result.disabled) foundDisabled = true;
1433
+ await page.waitForTimeout(500).catch(() => {});
1434
+ }
1435
+
1436
+ if (foundDisabled) {
1437
+ const forceClicked = await page.evaluate(() => {
1438
+ const clean = (value: string | null | undefined) =>
1439
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
1440
+ const textFor = (element: Element): string => {
1441
+ const labelledBy = element.getAttribute('aria-labelledby');
1442
+ const labelText = labelledBy
1443
+ ?.split(/\s+/)
1444
+ .map((id) => document.getElementById(id)?.textContent || '')
1445
+ .join(' ');
1446
+ return clean([
1447
+ element.textContent || '',
1448
+ element.getAttribute('aria-label') || '',
1449
+ element.getAttribute('title') || '',
1450
+ labelText || '',
1451
+ ].join(' '));
1452
+ };
1453
+ const button = (
1454
+ document.querySelector<HTMLElement>('.apply-changes-button') ||
1455
+ Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) =>
1456
+ textFor(element).includes('apply changes'),
1457
+ )
1458
+ ) as HTMLElement | null;
1459
+ if (!button) return false;
1460
+ button.classList.remove('disabled');
1461
+ button.removeAttribute('disabled');
1462
+ button.setAttribute('aria-disabled', 'false');
1463
+ button.scrollIntoView({ block: 'center', inline: 'center' });
1464
+ button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
1465
+ button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
1466
+ button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
1467
+ button.click();
1468
+ return true;
1469
+ }).catch(() => false);
1470
+
1471
+ if (forceClicked) {
1472
+ logger.warn('Legacy Apply Changes button stayed disabled; force-clicked after verified filter values');
1473
+ await page.waitForTimeout(2500).catch(() => {});
1474
+ const stillOpen = await page
1475
+ .locator('.apply-changes-button')
1476
+ .first()
1477
+ .waitFor({ state: 'visible', timeout: 1000 })
1478
+ .then(() => true)
1479
+ .catch(() => false);
1480
+ return !stillOpen;
1481
+ }
1482
+
1483
+ logger.warn('Legacy Apply Changes button stayed disabled after filter state verification');
1484
+ }
1485
+
1486
+ return false;
1487
+ }
1488
+
1489
+ async function readLegacyViewerSimilarity(page: Page): Promise<number | null> {
1490
+ await page.waitForTimeout(1000);
1491
+ const text = await page
1492
+ .locator('.osi-score, label[title="Match Overview"], [title="Match Overview"]')
1493
+ .first()
1494
+ .textContent({ timeout: 10000 })
1495
+ .catch(() => null);
1496
+ return parsePercent(text);
1497
+ }
1498
+
1499
+ async function clickLegacyCartaElement(
1500
+ page: Page,
1501
+ selectors: string[],
1502
+ textIncludes: string[],
1503
+ timeoutMs = 10000,
1504
+ ): Promise<boolean> {
1505
+ const deadline = Date.now() + timeoutMs;
1506
+ while (Date.now() < deadline) {
1507
+ const clicked = await page.evaluate(
1508
+ ({ selectors: rawSelectors, textIncludes: rawTextIncludes }) => {
1509
+ const clean = (value: string | null | undefined) =>
1510
+ String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
1511
+
1512
+ const textFor = (element: Element): string => {
1513
+ const labelledBy = element.getAttribute('aria-labelledby');
1514
+ const labelText = labelledBy
1515
+ ?.split(/\s+/)
1516
+ .map((id) => document.getElementById(id)?.textContent || '')
1517
+ .join(' ');
1518
+ return clean([
1519
+ element.textContent || '',
1520
+ element.getAttribute('aria-label') || '',
1521
+ element.getAttribute('title') || '',
1522
+ labelText || '',
1523
+ element.getAttribute('data-px') || '',
1524
+ ].join(' '));
1525
+ };
1526
+
1527
+ const visibleScore = (element: Element): number => {
1528
+ if (!(element instanceof HTMLElement)) return 0;
1529
+ const style = window.getComputedStyle(element);
1530
+ const rect = element.getBoundingClientRect();
1531
+ if (style.display === 'none' || style.visibility === 'hidden') return 0;
1532
+ if (rect.width > 0 && rect.height > 0) return 2;
1533
+ return 1;
1534
+ };
1535
+
1536
+ const textNeedles = rawTextIncludes.map(clean).filter(Boolean);
1537
+ const candidates: HTMLElement[] = [];
1538
+ for (const selector of rawSelectors) {
1539
+ try {
1540
+ candidates.push(...Array.from(document.querySelectorAll<HTMLElement>(selector)));
1541
+ } catch {
1542
+ // Ignore invalid selector fallbacks.
1543
+ }
1544
+ }
1545
+
1546
+ if (textNeedles.length > 0) {
1547
+ const controls = Array.from(
1548
+ document.querySelectorAll<HTMLElement>(
1549
+ '[role="button"], button, a, .sc-button-view, .sc-segment-view, .sc-list-item-view, .btn-link',
1550
+ ),
1551
+ );
1552
+ candidates.push(
1553
+ ...controls.filter((element) => {
1554
+ const text = textFor(element);
1555
+ return textNeedles.some((needle) => text.includes(needle));
1556
+ }),
1557
+ );
1558
+ }
1559
+
1560
+ const unique = Array.from(new Set(candidates));
1561
+ const target = unique
1562
+ .filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true')
1563
+ .sort((a, b) => visibleScore(b) - visibleScore(a))[0];
1564
+ if (!target) return false;
1565
+
1566
+ target.scrollIntoView({ block: 'center', inline: 'center' });
1567
+ target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
1568
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
1569
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
1570
+ target.click();
1571
+ return true;
1572
+ },
1573
+ { selectors, textIncludes },
1574
+ ).catch(() => false);
1575
+
1576
+ if (clicked) return true;
1577
+ await page.waitForTimeout(250).catch(() => {});
1578
+ }
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 {
1624
+ await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
1625
+ await page.waitForTimeout(attempt === 1 ? 1000 : 4000);
1626
+
1627
+ const opened = await clickFirstVisible(page, [
1628
+ '[data-px="EVSimReportDownloadClicked"]',
1629
+ '.sidebar-download-button',
1630
+ '[title="Download"]',
1631
+ 'button:has-text("Download")',
1632
+ ], 8000) || await clickLegacyCartaElement(page, [
1633
+ '[data-px="EVSimReportDownloadClicked"]',
1634
+ '.sidebar-download-button',
1635
+ '[title="Download"]',
1636
+ '[role="button"][title*="Download"]',
1637
+ '.tii-icon-download',
1638
+ ], ['download'], 8000);
1639
+
1640
+ if (!opened) {
1641
+ throw new Error('Legacy download menu button was not found.');
1642
+ }
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;
1660
+ await download.saveAs(outputPath);
1661
+ const stat = fs.statSync(outputPath);
1662
+ if (stat.size <= 0) {
1663
+ try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
1664
+ throw new Error(`Downloaded legacy PDF is empty: ${outputPath}`);
1665
+ }
1666
+ return outputPath;
1667
+ } catch (error) {
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) {
1675
+ await page.keyboard.press('Escape').catch(() => {});
1676
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ throw lastError || new Error(`Legacy ${optionLabel} PDF download failed.`);
1682
+ }
1683
+
1684
+ export async function runLegacyTurnitinJob(
1685
+ input: RunTurnitinJobInput,
1686
+ ): Promise<RunTurnitinJobResult> {
1687
+ const {
1688
+ account,
1689
+ assignmentTarget,
1690
+ inputFilePath,
1691
+ inputFileName,
1692
+ outputDir,
1693
+ mode: requestedMode,
1694
+ filters,
1695
+ storageStatePath,
1696
+ resumeAfterStep,
1697
+ resumeViewerUrl,
1698
+ onEvent,
1699
+ } = input;
1700
+
1701
+ const result: RunTurnitinJobResult = {};
1702
+ let page: Page | null = null;
1703
+ const browser = await getBrowser();
1704
+ const contextOptions: Record<string, unknown> = {
1705
+ userAgent:
1706
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
1707
+ viewport: { width: 1366, height: 768 },
1708
+ acceptDownloads: true,
1709
+ extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
1710
+ };
1711
+
1712
+ if (storageStatePath && fs.existsSync(storageStatePath)) {
1713
+ contextOptions.storageState = storageStatePath;
1714
+ }
1715
+
1716
+ const context = await browser.newContext(contextOptions as any);
1717
+
1718
+ try {
1719
+ await emit(onEvent, 'info', 'browser', 'Creating legacy browser context');
1720
+ page = await context.newPage();
1721
+
1722
+ await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', { email: account.email });
1723
+ await loginToTurnitin(page, account.email, account.password, storageStatePath, assignmentTarget.targetUrl);
1724
+ result.lastCompletedStep = 'login';
1725
+
1726
+ const resumeFromViewer =
1727
+ resumeAfterStep &&
1728
+ ['viewer', 'filters', 'download', 'receipt'].includes(resumeAfterStep) &&
1729
+ resumeViewerUrl;
1730
+
1731
+ if (resumeFromViewer) {
1732
+ await emit(onEvent, 'info', 'viewer', 'Reopening legacy viewer from previous attempt', {
1733
+ viewerUrl: resumeViewerUrl,
1734
+ });
1735
+ page = await context.newPage();
1736
+ await page.goto(resumeViewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
1737
+ await page.waitForTimeout(3000);
1738
+ result.viewerUrl = page.url();
1739
+ result.lastCompletedStep = 'viewer';
1740
+ } else {
1741
+ await emit(onEvent, 'info', 'navigate', 'Navigating to legacy assignment', {
1742
+ classTitle: assignmentTarget.classTitle,
1743
+ assignmentTitle: assignmentTarget.assignmentTitle,
1744
+ });
1745
+ await navigateLegacyAssignment(
1746
+ page,
1747
+ assignmentTarget.classTitle,
1748
+ assignmentTarget.assignmentTitle,
1749
+ onEvent,
1750
+ );
1751
+ // Persist 'navigate' step immediately so that on failure the retry
1752
+ // engine knows we reached the assignment page and can resume correctly.
1753
+ result.lastCompletedStep = 'navigate';
1754
+ await emit(onEvent, 'info', 'navigate', 'Legacy assignment page loaded');
1755
+
1756
+ if (requestedMode === 'quota_check') {
1757
+ const details = await openLegacyViewer(page, context)
1758
+ .then((viewer) => readLegacySubmissionDetails(viewer))
1759
+ .catch(() => null);
1760
+ if (details) result.submissionDetails = details;
1761
+ result.submissionCount = details?.submissionCount;
1762
+ result.lastCompletedStep = 'quota_check';
1763
+ return result;
1764
+ }
1765
+
1766
+ if (resumeAfterStep && ['submitted', 'similarity'].includes(resumeAfterStep)) {
1767
+ await emit(onEvent, 'info', 'resume', 'Resuming legacy job after previous upload; skipping upload step', {
1768
+ resumeAfterStep,
1769
+ });
1770
+ result.lastCompletedStep = 'submitted';
1771
+ } else {
1772
+ const { effectiveMode, scope } = await openLegacyUploadModal(page, requestedMode, onEvent);
1773
+ await emit(onEvent, 'info', effectiveMode, effectiveMode === 'resubmit' ? 'Resubmitting legacy file' : 'Uploading legacy file', {
1774
+ filePath: inputFilePath,
1775
+ });
1776
+ const reviewDetails = await completeLegacyUpload(scope, inputFilePath, inputFileName || path.basename(inputFilePath));
1777
+ if (reviewDetails) {
1778
+ result.submissionDetails = reviewDetails;
1779
+ await emit(onEvent, 'info', 'submission_details', 'Legacy review details captured', reviewDetails as Record<string, unknown>);
1780
+ }
1781
+ result.submittedAt = new Date().toISOString();
1782
+ result.lastCompletedStep = 'submitted';
1783
+ await emit(onEvent, 'info', 'submitted', 'File submitted successfully', {
1784
+ submittedAt: result.submittedAt,
1785
+ });
1786
+ }
1787
+
1788
+ await emit(onEvent, 'info', 'similarity', 'Waiting for legacy similarity score');
1789
+ const tableSimilarity = await waitForLegacySimilarity(page);
1790
+ if (tableSimilarity !== null) result.similarityPercent = tableSimilarity;
1791
+ result.lastCompletedStep = 'similarity';
1792
+ await emit(onEvent, 'info', 'similarity', `Similarity: ${result.similarityPercent ?? 'pending'}%`, {
1793
+ similarityPercent: result.similarityPercent,
1794
+ });
1795
+
1796
+ await emit(onEvent, 'info', 'viewer', 'Opening legacy report viewer');
1797
+ page = await openLegacyViewer(page, context);
1798
+ result.viewerUrl = page.url();
1799
+ result.lastCompletedStep = 'viewer';
1800
+ await emit(onEvent, 'info', 'viewer', 'Legacy report viewer opened', {
1801
+ viewerUrl: result.viewerUrl,
1802
+ });
1803
+ }
1804
+
1805
+ const details = await readLegacySubmissionDetails(page);
1806
+ if (details) {
1807
+ result.submissionDetails = { ...(result.submissionDetails || {}), ...details };
1808
+ result.submissionCount = details.submissionCount;
1809
+ await emit(onEvent, 'info', 'submission_details', 'Legacy submission details captured', result.submissionDetails as Record<string, unknown>);
1810
+ }
1811
+
1812
+ await emit(onEvent, 'info', 'filters', 'Applying legacy filters', { filters });
1813
+ await applyLegacyFilters(page, filters);
1814
+ result.lastCompletedStep = 'filters';
1815
+
1816
+ const viewerSimilarity = await readLegacyViewerSimilarity(page);
1817
+ if (viewerSimilarity !== null) {
1818
+ result.similarityPercent = viewerSimilarity;
1819
+ await emit(onEvent, 'info', 'similarity', `Viewer similarity (post-filter): ${viewerSimilarity}%`, {
1820
+ similarityPercent: viewerSimilarity,
1821
+ filtered: hasActiveFilters(filters),
1822
+ });
1823
+ }
1824
+
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
1859
+ : null;
1860
+ const shouldPermanentlyLimitLegacy =
1861
+ (typeof result.submissionCount === 'number' && result.submissionCount >= LEGACY_ACCOUNT_QUOTA_LIMIT) ||
1862
+ accountQuotaRemaining === 1;
1863
+
1864
+ if (shouldPermanentlyLimitLegacy) {
1865
+ const message =
1866
+ 'Legacy Turnitin account reached its 4-submission limit. Class will be dropped and account will be permanently limited.';
1867
+ await emit(onEvent, 'warning', 'class_cleanup', 'Legacy submission limit reached; dropping class from account', {
1868
+ classTitle: assignmentTarget.classTitle,
1869
+ submissionCount: result.submissionCount ?? null,
1870
+ accountQuotaRemaining,
1871
+ });
1872
+ const dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({
1873
+ attempted: true,
1874
+ dropped: false,
1875
+ reason: dropError instanceof Error ? dropError.message : String(dropError),
1876
+ }));
1877
+ await emit(
1878
+ onEvent,
1879
+ dropClassResult.dropped ? 'info' : 'warning',
1880
+ 'class_cleanup',
1881
+ dropClassResult.dropped
1882
+ ? 'Legacy class dropped or already absent after submission limit'
1883
+ : 'Legacy class could not be dropped automatically after submission limit',
1884
+ { ...dropClassResult },
1885
+ );
1886
+ result.permanentLimit = {
1887
+ message,
1888
+ submissionCount: result.submissionCount,
1889
+ dropClassResult,
1890
+ };
1891
+ }
1892
+
1893
+ if (storageStatePath) {
1894
+ await context.storageState({ path: storageStatePath }).catch(() => {});
1895
+ }
1896
+
1897
+ return result;
1898
+ } catch (error) {
1899
+ const message = error instanceof Error ? error.message : String(error);
1900
+ if (page) {
1901
+ const screenshotPath = path.join(outputDir || '/tmp', `legacy_error_${Date.now()}.png`);
1902
+ await page.screenshot({ path: screenshotPath }).catch(() => {});
1903
+ logger.info(`Saved error screenshot to ${screenshotPath}`);
1904
+ }
1905
+ await emit(onEvent, 'error', 'error', compactErrorMessage(message), {
1906
+ errorName: error instanceof Error ? error.name : 'UnknownError',
1907
+ });
1908
+ if (error && typeof error === 'object') {
1909
+ (error as any).lastCompletedStep = result.lastCompletedStep;
1910
+ (error as any).viewerUrl = result.viewerUrl;
1911
+ (error as any).similarityPercent = result.similarityPercent;
1912
+ }
1913
+ throw error;
1914
+ } finally {
1915
+ await context.close().catch(() => {});
1916
+ }
1917
+ }
src/engine/selectors.ts ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // CSS Selectors – grouped by workflow area
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export const SELECTORS = {
8
+ // ---- Login ----
9
+ login: {
10
+ emailInput: '#email',
11
+ passwordInput: '#password',
12
+ submitButton: 'input.submit[type="submit"]',
13
+ },
14
+
15
+ // ---- EULA ----
16
+ eula: {
17
+ agreementModalSaveBtn: '#eulaAgreementModalSaveBtn',
18
+ iAgreeButton: 'button:has-text("I Agree")',
19
+ acceptButton: 'button:has-text("Accept")',
20
+ tdlIAgreeButton: 'tdl-button:has-text("I Agree")',
21
+ tdlAcceptButton: 'tdl-button:has-text("Accept")',
22
+ iAgreeContinueLink: 'a:has-text("I Agree -- Continue")',
23
+ },
24
+
25
+ // ---- Class ----
26
+ class: {
27
+ classNameLink: 'td.class_name a, a.ib-class-link, a',
28
+ },
29
+
30
+ // ---- Assignment ----
31
+ assignment: {
32
+ assignmentRow: 'tr.assignment-row',
33
+ openButton: 'a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")',
34
+ allOpenButtons:
35
+ 'tr.assignment-row a.btn-open, a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")',
36
+ },
37
+
38
+ // ---- Upload ----
39
+ upload: {
40
+ fileInput: 'input[type="file"]',
41
+ browseButton:
42
+ // New UI: tii-ing-upload-button > tdl-button[part="upload-form-container-button"]
43
+ // Old UI: tdl-button with "Browse Files" text
44
+ 'tdl-button[part="upload-form-container-button"], tii-ing-upload-button tdl-button, button:has-text("Browse Files"), button:has-text("Browse"), tdl-button:has-text("Browse")',
45
+ uploadFromDevice:
46
+ 'tdl-button[part="upload-form-container-menu-browse-button"], .actions-menu tdl-button[part="upload-form-container-menu-browse-button"], button:has-text("Your device"), tdl-button:has-text("Your device"), button:has-text("Upload from this device"), tdl-button:has-text("Upload from this device")',
47
+ submitButton:
48
+ // New UI: tdl-button with slot="form-submit-button-label" containing "Upload and Preview"
49
+ // Also handles plain Submit/Confirm after preview, plus accept-button/tii-grn-button
50
+ 'tdl-button[part="submit-button"], tdl-button:has-text("Upload and Preview"), tdl-button.accept-button, tii-grn-button, button:has-text("Upload"), tdl-button:has-text("Upload"), button:has-text("Confirm"), tdl-button:has-text("Confirm"), button:has-text("Submit"), tdl-button:has-text("Submit")',
51
+ // New UI container for upload step
52
+ uploadStepContainer: 'div.upload-step, tii-ing-dropzone',
53
+ },
54
+
55
+ // ---- Resubmit ----
56
+ resubmit: {
57
+ // New UI: tii-grn-button with text "Resubmit" (inside tii-workflow-student-summary-panel-new)
58
+ // Old UI: tdl-button or button with text "Resubmit"
59
+ resubmitButton:
60
+ 'tii-grn-button:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"]',
61
+ // New submission action wrapper (contains Submit/Resubmit button)
62
+ submissionActionPanel: 'tii-workflow-student-summary-panel-new, div.submission-action',
63
+ // New resubmission confirmation modal (dialog element)
64
+ resubmissionConfirmModal: 'dialog#tii-resubmission-last-submission-modal',
65
+ // Accept button inside the new modal (slot-based)
66
+ resubmissionConfirmAccept:
67
+ 'dialog#tii-resubmission-last-submission-modal [slot="accept-button"] tdl-button, dialog#tii-resubmission-last-submission-modal .accept-button, dialog#tii-resubmission-last-submission-modal tdl-button.accept-button',
68
+ },
69
+
70
+ // ---- Submission Card / Similarity ----
71
+ similarity: {
72
+ viewSubmissionButton:
73
+ 'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button',
74
+ similarityDisplay: 'tii-mpdd-similarity-display',
75
+ similarityBadge: 'span[part="tii-grn-badge-label"]',
76
+ similaritySpan: 'tii-mpdd-similarity-display span:has-text("Similarity:")',
77
+ bodyContainer: '.body-container',
78
+ linkButtons: 'button.link-button',
79
+ },
80
+
81
+ // ---- Viewer ----
82
+ viewer: {
83
+ titleButton:
84
+ 'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button, [part="tii-grn-badge-label"]',
85
+ },
86
+
87
+ // ---- Filters ----
88
+ filters: {
89
+ similarityTab:
90
+ [
91
+ 'tii-sws-tab-button#tab-similarity',
92
+ '#tab-similarity',
93
+ 'tii-sws-tab-button[aria-controls="tii-sws-main"]:has-text("Similarity")',
94
+ 'tii-sws-tab-button:has-text("Similarity")',
95
+ 'button.tab-button[data-px="SimilarityTabClicked"]',
96
+ '[data-px="SimilarityTabClicked"]',
97
+ '[with-data-px="SimilarityTabClicked"]',
98
+ '[withdatapx="SimilarityTabClicked"]',
99
+ '[data-px*="SimilarityTab"]',
100
+ '[with-data-px*="SimilarityTab"]',
101
+ '[withdatapx*="SimilarityTab"]',
102
+ '[role="tab"]:has-text("Similarity")',
103
+ 'button:has-text("Similarity")',
104
+ 'tdl-button:has-text("Similarity")',
105
+ 'tdl-labeled-button:has-text("Similarity")',
106
+ '[aria-label*="Similarity"]',
107
+ ].join(', '),
108
+ filterButton:
109
+ [
110
+ '.tii-SimilarityReportPanel[aria-hidden="false"] .tii-SimilarityReportPanelHeader__SettingsButton',
111
+ '.tii-SimilarityReportPanel[aria-hidden="false"] tdl-button[with-data-px="SettingsClicked"]',
112
+ '.tii-filters-and-exclusions-container tdl-button[with-data-px="SettingsClicked"]',
113
+ 'tdl-button[with-data-px="SettingsClicked"]',
114
+ 'tdl-button[withdatapx="SettingsClicked"]',
115
+ '[with-data-px="SettingsClicked"]',
116
+ '[withdatapx="SettingsClicked"]',
117
+ '[data-px="SettingsClicked"]',
118
+ '[data-px*="Settings"]',
119
+ '[with-data-px*="Settings"]',
120
+ '[withdatapx*="Settings"]',
121
+ '.tii-SimilarityReportPanelHeader__SettingsButton',
122
+ 'tdl-button:has-text("Filters")',
123
+ 'tdl-labeled-button:has-text("Filters")',
124
+ 'tii-grn-button:has-text("Filters")',
125
+ 'button:has-text("Filters")',
126
+ '[role="button"]:has-text("Filters")',
127
+ '[aria-label*="Filter"]',
128
+ '[title*="Filter"]',
129
+ '[part*="filter"]',
130
+ ].join(', '),
131
+ excludeBibliography: '#excludeBibliography, [with-id="excludeBibliography"]',
132
+ excludeQuotes: '#excludeQuotes, [with-id="excludeQuotes"]',
133
+ excludeCitations: '#excludeCitations, [with-id="excludeCitations"]',
134
+ excludeSmallMatches:
135
+ 'tdl-checkbox[with-px-label="ExcludeSmallMatches"], tdl-checkbox:has-text("Exclude small matches")',
136
+ smallMatchesInput:
137
+ 'tdl-number-input.small-matches-input, tdl-number-input[label="Set match exclusion threshold"]',
138
+ applyFilters:
139
+ [
140
+ 'tdl-button[with-data-px="ApplyFiltersClicked"]',
141
+ 'tdl-button[withdatapx="ApplyFiltersClicked"]',
142
+ '[with-data-px="ApplyFiltersClicked"]',
143
+ '[withdatapx="ApplyFiltersClicked"]',
144
+ '[data-px="ApplyFiltersClicked"]',
145
+ 'tdl-button[slot="accept-button"]:has-text("Apply Filters")',
146
+ 'tdl-button:has-text("Apply Filters")',
147
+ 'button:has-text("Apply Filters")',
148
+ ].join(', '),
149
+ backToReport:
150
+ [
151
+ '[id="supplement-container.viewSettings"] button.tii-similarity-supplement--back-button',
152
+ '.tii-panel-overlay--is-open button.tii-similarity-supplement--back-button',
153
+ 'button.tii-similarity-supplement--back-button',
154
+ '[data-px="FiltersBackToReportClicked"]',
155
+ '[with-data-px="FiltersBackToReportClicked"]',
156
+ '[withdatapx="FiltersBackToReportClicked"]',
157
+ 'button:has-text("Back to Similarity Report")',
158
+ 'tdl-button:has-text("Back to Similarity Report")',
159
+ ].join(', '),
160
+ },
161
+
162
+ // ---- Download ----
163
+ download: {
164
+ downloadMenu:
165
+ [
166
+ 'tii-sws-download-btn-mfe tdl-labeled-button',
167
+ 'tii-sws-download-btn-mfe',
168
+ 'tii-sws-header [slot="download-btn"]',
169
+ 'tdl-labeled-button[withdatapx="DownloadMenuClicked"]',
170
+ '[withdatapx="DownloadMenuClicked"]',
171
+ 'tdl-labeled-button:has-text("Download")',
172
+ 'button:has-text("Download")',
173
+ ].join(', '),
174
+ currentViewOption:
175
+ [
176
+ '[with-data-px="DownloadOptionCurrentViewClicked"]',
177
+ '[withdatapx="DownloadOptionCurrentViewClicked"]',
178
+ '[data-px="DownloadOptionCurrentViewClicked"]',
179
+ ].join(', '),
180
+ },
181
+
182
+ // ---- Quota ----
183
+ quota: {
184
+ warningConfirmButton:
185
+ 'tii-grn-button:has-text("Continue"), tii-grn-button.accept-button, tdl-button[slot="accept-button"], tdl-button[variety="filled"], tii-grn-button',
186
+ },
187
+ };
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // EULA selector list (iterated during acceptance)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export const EULA_SELECTORS = [
194
+ SELECTORS.eula.agreementModalSaveBtn,
195
+ SELECTORS.eula.iAgreeButton,
196
+ SELECTORS.eula.acceptButton,
197
+ SELECTORS.eula.tdlIAgreeButton,
198
+ SELECTORS.eula.tdlAcceptButton,
199
+ SELECTORS.eula.iAgreeContinueLink,
200
+ ];
201
+
202
+ export const EULA_BODY_REGEX =
203
+ /End User License Agreement|Terms of Use|I Agree|I Accept/i;
204
+
205
+ export const EULA_DEEP_CLICK_TEXTS = [
206
+ 'i agree',
207
+ 'i accept',
208
+ 'accept',
209
+ 'continue',
210
+ 'save',
211
+ ];
212
+
213
+ export const EULA_BLOCK_REGEX =
214
+ /Could not access the assignment|End User License Agreement needs to be accepted|EULA needs to be accepted/i;
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Quota warning confirmation labels (in priority order)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ export const QUOTA_CONFIRM_LABELS = [
221
+ 'Continue to File Upload',
222
+ 'Continue',
223
+ 'Confirm',
224
+ 'I understand',
225
+ 'OK',
226
+ 'Yes',
227
+ ];
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Shadow DOM helpers
231
+ // ---------------------------------------------------------------------------
232
+
233
+ type Scope = Page | Frame;
234
+
235
+ /**
236
+ * Read all text from a scope, traversing into shadow DOMs.
237
+ */
238
+ export async function readScopeDeepText(scope: Scope): Promise<string> {
239
+ return scope.evaluate(() => {
240
+ const roots: (Document | ShadowRoot)[] = [document];
241
+ const seen = new Set<Element>();
242
+
243
+ for (let i = 0; i < roots.length; i++) {
244
+ const root = roots[i];
245
+ for (const el of Array.from(root.querySelectorAll('*'))) {
246
+ if (seen.has(el)) continue;
247
+ seen.add(el);
248
+ if (el.shadowRoot) roots.push(el.shadowRoot);
249
+ }
250
+ }
251
+
252
+ return roots
253
+ .map((r) => (r as any).body?.innerText || (r as any).host?.innerText || r.textContent || '')
254
+ .join(' ')
255
+ .replace(/\s+/g, ' ')
256
+ .trim();
257
+ }).catch(() => '');
258
+ }
259
+
260
+ /**
261
+ * Read only visible text from a scope, traversing into shadow DOMs.
262
+ */
263
+ export async function readScopeVisibleDeepText(scope: Scope): Promise<string> {
264
+ return scope.evaluate(() => {
265
+ const roots: (Document | ShadowRoot)[] = [document];
266
+ const seen = new Set<Element>();
267
+
268
+ for (let i = 0; i < roots.length; i++) {
269
+ const root = roots[i];
270
+ for (const el of Array.from(root.querySelectorAll('*'))) {
271
+ if (seen.has(el)) continue;
272
+ seen.add(el);
273
+ if (el.shadowRoot) roots.push(el.shadowRoot);
274
+ }
275
+ }
276
+
277
+ const isVisible = (el: Element): boolean => {
278
+ if (!(el instanceof HTMLElement)) return false;
279
+ const style = window.getComputedStyle(el);
280
+ const rect = el.getBoundingClientRect();
281
+ return (
282
+ style.visibility !== 'hidden' &&
283
+ style.display !== 'none' &&
284
+ rect.width > 0 &&
285
+ rect.height > 0
286
+ );
287
+ };
288
+
289
+ const chunks: string[] = [];
290
+ for (const root of roots) {
291
+ for (const el of Array.from(root.querySelectorAll('*'))) {
292
+ if (!isVisible(el)) continue;
293
+ const text = (el as HTMLElement).innerText || '';
294
+ if (text) chunks.push(text);
295
+ }
296
+ }
297
+
298
+ return chunks.join(' ').replace(/\s+/g, ' ').trim();
299
+ }).catch(() => '');
300
+ }
301
+
302
+ /**
303
+ * Check if any of textMatches appear in deep text of the scope.
304
+ */
305
+ export async function scopeHasDeepText(
306
+ scope: Scope,
307
+ textMatches: string[],
308
+ ): Promise<boolean> {
309
+ const text = await readScopeDeepText(scope);
310
+ const normalized = text.toLowerCase();
311
+ return textMatches.some((m) => normalized.includes(m.toLowerCase()));
312
+ }
313
+
314
+ /**
315
+ * Check if any of textMatches appear in visible deep text of the scope.
316
+ * This avoids matching hidden web-component templates that Turnitin keeps in
317
+ * the DOM for states that are not currently active.
318
+ */
319
+ export async function scopeHasVisibleDeepText(
320
+ scope: Scope,
321
+ textMatches: string[],
322
+ ): Promise<boolean> {
323
+ const text = await readScopeVisibleDeepText(scope);
324
+ const normalized = text.toLowerCase();
325
+ return textMatches.some((m) => normalized.includes(m.toLowerCase()));
326
+ }
327
+
328
+ /**
329
+ * Click a visible checkbox in the given scope.
330
+ */
331
+ export async function clickVisibleCheckbox(scope: Scope): Promise<boolean> {
332
+ return scope
333
+ .evaluate(() => {
334
+ const isVisible = (el: Element): boolean => {
335
+ const style = window.getComputedStyle(el);
336
+ const rect = el.getBoundingClientRect();
337
+ return (
338
+ style.visibility !== 'hidden' &&
339
+ style.display !== 'none' &&
340
+ rect.width > 0 &&
341
+ rect.height > 0
342
+ );
343
+ };
344
+
345
+ const cb = Array.from(
346
+ document.querySelectorAll(
347
+ 'input[type="checkbox"], tdl-checkbox, tii-grn-checkbox',
348
+ ),
349
+ ).find(isVisible);
350
+ if (!cb) return false;
351
+ (cb as HTMLElement).click();
352
+ return true;
353
+ })
354
+ .catch(() => false);
355
+ }
356
+
357
+ /**
358
+ * Traverse shadow DOM looking for clickable elements whose text matches one of
359
+ * the given strings, then click the first match.
360
+ */
361
+ export async function deepClickByText(
362
+ scope: Scope,
363
+ textMatches: string[],
364
+ ): Promise<boolean> {
365
+ return scope
366
+ .evaluate((matches: string[]) => {
367
+ const normalizedMatches = matches.map((v) => v.toLowerCase());
368
+ const roots: (Document | ShadowRoot)[] = [document];
369
+ const seen = new Set<Element>();
370
+
371
+ for (let i = 0; i < roots.length; i++) {
372
+ const root = roots[i];
373
+ for (const el of Array.from(root.querySelectorAll('*'))) {
374
+ if (seen.has(el)) continue;
375
+ seen.add(el);
376
+ if (el.shadowRoot) roots.push(el.shadowRoot);
377
+ }
378
+ }
379
+
380
+ const isVisible = (el: Element): boolean => {
381
+ if (!(el instanceof HTMLElement)) return false;
382
+ const style = window.getComputedStyle(el);
383
+ const rect = el.getBoundingClientRect();
384
+ return (
385
+ style.visibility !== 'hidden' &&
386
+ style.display !== 'none' &&
387
+ rect.width > 0 &&
388
+ rect.height > 0
389
+ );
390
+ };
391
+
392
+ const candidates: Element[] = [];
393
+ for (const root of roots) {
394
+ candidates.push(
395
+ ...Array.from(
396
+ root.querySelectorAll(
397
+ 'button, a, input, tdl-button, tii-grn-button, tdl-labeled-button',
398
+ ),
399
+ ),
400
+ );
401
+ }
402
+
403
+ const target = candidates.find((el) => {
404
+ if (!isVisible(el)) return false;
405
+ const text = [
406
+ (el as HTMLElement).innerText,
407
+ el.textContent,
408
+ el.getAttribute('aria-label'),
409
+ el.getAttribute('value'),
410
+ ]
411
+ .filter(Boolean)
412
+ .join(' ')
413
+ .replace(/\s+/g, ' ')
414
+ .trim()
415
+ .toLowerCase();
416
+ return normalizedMatches.some((m) => text.includes(m));
417
+ });
418
+
419
+ if (!target) return false;
420
+ (target as HTMLElement).click();
421
+ return true;
422
+ }, textMatches)
423
+ .catch(() => false);
424
+ }
425
+
426
+ /**
427
+ * Find the scope (page or frame) that contains a visible element matching the
428
+ * given selector.
429
+ */
430
+ export async function findAssignmentScope(
431
+ page: Page,
432
+ selector: string,
433
+ timeoutMs = 1000,
434
+ ): Promise<Page | Frame | null> {
435
+ if (
436
+ await page
437
+ .locator(selector)
438
+ .first()
439
+ .isVisible({ timeout: timeoutMs })
440
+ .catch(() => false)
441
+ )
442
+ return page;
443
+
444
+ for (const frame of page.frames()) {
445
+ if (frame.url().includes('cookie-shim')) continue;
446
+ if (
447
+ await frame
448
+ .locator(selector)
449
+ .first()
450
+ .isVisible({ timeout: timeoutMs })
451
+ .catch(() => false)
452
+ )
453
+ return frame;
454
+ }
455
+
456
+ return null;
457
+ }
458
+
459
+ /**
460
+ * Find the scope (page or frame) that contains deep text matching any of the
461
+ * given strings, polling until timeoutMs.
462
+ */
463
+ export async function findScopeByDeepText(
464
+ page: Page,
465
+ textMatches: string[],
466
+ timeoutMs = 30000,
467
+ ): Promise<Page | Frame | null> {
468
+ const deadline = Date.now() + timeoutMs;
469
+ while (Date.now() < deadline) {
470
+ if (await scopeHasDeepText(page, textMatches)) return page;
471
+
472
+ for (const frame of page.frames()) {
473
+ if (frame.url().includes('cookie-shim')) continue;
474
+ if (await scopeHasDeepText(frame, textMatches)) return frame;
475
+ }
476
+
477
+ await page.waitForTimeout(1000);
478
+ }
479
+
480
+ return null;
481
+ }
482
+
483
+ /**
484
+ * Resolve the best scope for the assignment page.
485
+ */
486
+ export async function resolveAssignmentScope(
487
+ page: Page,
488
+ ): Promise<Page | Frame> {
489
+ return (
490
+ (await findAssignmentScope(
491
+ page,
492
+ 'button.link-button[aria-label*="View submission"], tii-mpdd-similarity-display',
493
+ 1000,
494
+ )) ||
495
+ (await findAssignmentScope(
496
+ page,
497
+ 'tdl-button[part="upload-form-container-button"], button:has-text("Browse Files"), input[type="file"]',
498
+ 1000,
499
+ )) ||
500
+ page
501
+ );
502
+ }
src/engine/steps/class-management.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import { SELECTORS } from '../selectors';
4
+ import { acceptEulaEverywhere } from './login';
5
+
6
+ export interface DropClassResult {
7
+ attempted: boolean;
8
+ dropped: boolean;
9
+ reason?: string;
10
+ }
11
+
12
+ /**
13
+ * Drop a class from the Turnitin student home page. This is used only after a
14
+ * hard submission quota limit so exhausted accounts do not keep showing stale
15
+ * class entries during future quota scans.
16
+ */
17
+ export async function dropClassByTitle(
18
+ page: Page,
19
+ classTitle: string,
20
+ ): Promise<DropClassResult> {
21
+ if (!classTitle || classTitle.trim() === '') {
22
+ return {
23
+ attempted: false,
24
+ dropped: false,
25
+ reason: 'Class title is empty',
26
+ };
27
+ }
28
+
29
+ logger.info('Attempting to drop quota-limited class', { classTitle });
30
+
31
+ await page.goto('https://www.turnitin.com/s_home.asp?lang=en_us', {
32
+ waitUntil: 'domcontentloaded',
33
+ timeout: 60000,
34
+ });
35
+ await page.waitForTimeout(1500);
36
+ await acceptEulaEverywhere(page);
37
+
38
+ const classLink = page
39
+ .locator(SELECTORS.class.classNameLink)
40
+ .filter({ hasText: classTitle })
41
+ .first();
42
+
43
+ if (!(await classLink.isVisible({ timeout: 10000 }).catch(() => false))) {
44
+ logger.info('Class is already absent from Turnitin home', { classTitle });
45
+ return {
46
+ attempted: true,
47
+ dropped: true,
48
+ reason: 'Class already absent',
49
+ };
50
+ }
51
+
52
+ const classRow = page.locator('tr').filter({ hasText: classTitle }).first();
53
+ const deleteLink = classRow
54
+ .locator('td.class_delete a, a[href*="confirmDropClass"]')
55
+ .first();
56
+
57
+ if (!(await deleteLink.isVisible({ timeout: 5000 }).catch(() => false))) {
58
+ logger.warn('Class delete link was not found', { classTitle });
59
+ return {
60
+ attempted: true,
61
+ dropped: false,
62
+ reason: 'Delete link not found',
63
+ };
64
+ }
65
+
66
+ let dialogAccepted = false;
67
+ const dialogHandler = async (dialog: any) => {
68
+ dialogAccepted = true;
69
+ await dialog.accept().catch(() => {});
70
+ };
71
+
72
+ page.once('dialog', dialogHandler);
73
+ await Promise.all([
74
+ page
75
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 })
76
+ .catch(() => null),
77
+ deleteLink.click({ force: true }),
78
+ ]);
79
+ await page.waitForTimeout(2500);
80
+
81
+ if (!dialogAccepted) {
82
+ logger.warn('Drop class confirmation dialog was not observed', {
83
+ classTitle,
84
+ });
85
+ }
86
+
87
+ await page.goto('https://www.turnitin.com/s_home.asp?lang=en_us', {
88
+ waitUntil: 'domcontentloaded',
89
+ timeout: 60000,
90
+ });
91
+ await page.waitForTimeout(1500);
92
+
93
+ const stillVisible = await page
94
+ .locator(SELECTORS.class.classNameLink)
95
+ .filter({ hasText: classTitle })
96
+ .first()
97
+ .isVisible({ timeout: 5000 })
98
+ .catch(() => false);
99
+
100
+ if (stillVisible) {
101
+ logger.warn('Class still visible after delete attempt', { classTitle });
102
+ return {
103
+ attempted: true,
104
+ dropped: false,
105
+ reason: 'Class still visible after delete',
106
+ };
107
+ }
108
+
109
+ logger.info('Quota-limited class dropped successfully', { classTitle });
110
+ return { attempted: true, dropped: true };
111
+ }
src/engine/steps/download.ts ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame, BrowserContext, Download } from 'playwright';
2
+ import * as fs from 'fs';
3
+ import { logger } from '../../utils/logger';
4
+ import { SELECTORS, deepClickByText } from '../selectors';
5
+
6
+ type Scope = Page | Frame;
7
+
8
+ async function clickFirstVisible(
9
+ page: Scope,
10
+ selector: string,
11
+ limit = 20,
12
+ ): Promise<boolean> {
13
+ const locator = page.locator(selector);
14
+ const count = Math.min(await locator.count().catch(() => 0), limit);
15
+
16
+ for (let index = 0; index < count; index++) {
17
+ const candidate = locator.nth(index);
18
+ if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) {
19
+ continue;
20
+ }
21
+ await candidate.click({ force: true });
22
+ return true;
23
+ }
24
+
25
+ return false;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Context-level download listener
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Wait for a download event across all pages in the context.
34
+ * Turnitin may open/close temporary pages during export, so we listen at the
35
+ * context level rather than on a single page.
36
+ */
37
+ function waitForAnyDownload(
38
+ context: BrowserContext,
39
+ timeoutMs: number,
40
+ ): Promise<Download> {
41
+ return new Promise((resolve, reject) => {
42
+ const timer = setTimeout(
43
+ () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)),
44
+ timeoutMs,
45
+ );
46
+ const pageListeners = new Map<Page, (d: Download) => void>();
47
+
48
+ const onDownload = (download: Download) => cleanup(null, download);
49
+ const onPage = (p: Page) => attach(p);
50
+
51
+ function attach(p: Page) {
52
+ p.on('download', onDownload);
53
+ pageListeners.set(p, onDownload);
54
+ }
55
+
56
+ function cleanup(error: Error | null, download?: Download) {
57
+ clearTimeout(timer);
58
+ context.off('page', onPage);
59
+ for (const [p, listener] of pageListeners) {
60
+ p.off('download', listener);
61
+ }
62
+ if (error) reject(error);
63
+ else resolve(download!);
64
+ }
65
+
66
+ for (const p of context.pages()) attach(p);
67
+ context.on('page', onPage);
68
+ });
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Public API
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Download the Turnitin report as a PDF from the viewer page.
77
+ *
78
+ * Steps:
79
+ * 1. Set up context-level download listener (not page-level)
80
+ * 2. Click download menu
81
+ * 3. Select Current View / Similarity Report
82
+ * 4. Wait for download to complete
83
+ * 5. Save to outputPath
84
+ *
85
+ * @returns The actual file path where the PDF was saved.
86
+ */
87
+ export async function downloadPdf(
88
+ page: Scope,
89
+ context: BrowserContext,
90
+ outputPath: string,
91
+ ): Promise<string> {
92
+ const ownerPage =
93
+ typeof (page as any).page === 'function'
94
+ ? (page as Frame).page()
95
+ : (page as Page);
96
+
97
+ // Validate that we are on the correct viewer URL.
98
+ // Check both the scope URL (Frame or Page) and the owner page URL —
99
+ // the report domain may only appear on one of them.
100
+ const scopeUrl = page.url();
101
+ const ownerUrl = ownerPage.url();
102
+ const isValidViewerUrl = (url: string) =>
103
+ url.includes('reports.integrity.turnitin.com') &&
104
+ /submission-viewer|\/submission\//i.test(url);
105
+
106
+ if (!isValidViewerUrl(scopeUrl) && !isValidViewerUrl(ownerUrl)) {
107
+ throw new Error(
108
+ `Cannot download report because viewer page is not active. ` +
109
+ `scope URL: ${scopeUrl}, owner URL: ${ownerUrl}`,
110
+ );
111
+ }
112
+
113
+ // ── BUG-5 FIX: Attach download listener BEFORE clicking any menu ──
114
+ // Previously the listener was set up after a 1-second wait post-click.
115
+ // If the browser starts the download faster than 1s the event was missed
116
+ // and the job timed out after 120 seconds.
117
+ const downloadPromise = waitForAnyDownload(context, 120000);
118
+
119
+ // Click download menu
120
+ let clickedDownloadMenu = false;
121
+ const downloadMenuDeadline = Date.now() + 30_000;
122
+ while (Date.now() < downloadMenuDeadline && !clickedDownloadMenu) {
123
+ clickedDownloadMenu = await clickFirstVisible(
124
+ page,
125
+ SELECTORS.download.downloadMenu,
126
+ 20,
127
+ );
128
+ if (!clickedDownloadMenu) {
129
+ await ownerPage.waitForTimeout(1000);
130
+ }
131
+ }
132
+ if (!clickedDownloadMenu) {
133
+ const clicked = await deepClickByText(page, ['download']);
134
+ if (!clicked) {
135
+ throw new Error('Download menu button was not found in report viewer');
136
+ }
137
+ }
138
+ await ownerPage.waitForTimeout(1000);
139
+
140
+ const optionSelectors = [
141
+ SELECTORS.download.currentViewOption,
142
+ '[with-data-px="DownloadOptionCurrentViewClicked"]',
143
+ '[data-px="DownloadOptionCurrentViewClicked"]',
144
+ 'button:has-text("Current View")',
145
+ 'tdl-button:has-text("Current View")',
146
+ '[role="menuitem"]:has-text("Current View")',
147
+ 'button:has-text("Similarity Report")',
148
+ 'tdl-button:has-text("Similarity Report")',
149
+ '[role="menuitem"]:has-text("Similarity Report")',
150
+ ];
151
+
152
+ let clickedOption = false;
153
+ for (const selector of optionSelectors) {
154
+ if (await clickFirstVisible(page, selector, 10)) {
155
+ clickedOption = true;
156
+ break;
157
+ }
158
+ }
159
+
160
+ if (!clickedOption) {
161
+ clickedOption = await deepClickByText(page, [
162
+ 'current view',
163
+ 'download current view',
164
+ 'similarity report',
165
+ 'download similarity report',
166
+ ]);
167
+ }
168
+
169
+ if (!clickedOption) {
170
+ throw new Error('Download option was not found after opening download menu');
171
+ }
172
+
173
+ const download = await downloadPromise;
174
+ await download.saveAs(outputPath);
175
+
176
+ const stat = fs.statSync(outputPath);
177
+ if (stat.size <= 0) {
178
+ // BUG-12 FIX: Remove the empty file so it doesn't accumulate in tmpDir
179
+ try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
180
+ throw new Error(`Downloaded PDF is empty: ${outputPath}`);
181
+ }
182
+
183
+ logger.info('PDF downloaded', {
184
+ outputPath,
185
+ suggestedFilename: download.suggestedFilename(),
186
+ size: stat.size,
187
+ });
188
+
189
+ return outputPath;
190
+ }
src/engine/steps/filters.ts ADDED
@@ -0,0 +1,780 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ SELECTORS,
5
+ deepClickByText,
6
+ readScopeVisibleDeepText,
7
+ } from '../selectors';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface FilterOptions {
14
+ excludeBibliography?: boolean;
15
+ excludeQuotes?: boolean;
16
+ excludeCitations?: boolean;
17
+ excludeSmallMatches?: boolean;
18
+ smallMatchMode?: 'words' | 'percent' | 'off' | null;
19
+ smallMatchThreshold?: number | null;
20
+ }
21
+
22
+ /**
23
+ * Returns `true` when the user has enabled at least one filter option.
24
+ * Used by the orchestrator to decide whether filter application is mandatory.
25
+ */
26
+ export function hasActiveFilters(filters: FilterOptions): boolean {
27
+ return Boolean(
28
+ filters.excludeBibliography ||
29
+ filters.excludeQuotes ||
30
+ filters.excludeCitations ||
31
+ filters.excludeSmallMatches,
32
+ );
33
+ }
34
+
35
+ type Scope = Page | Frame;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Resolve the owner Page from a Scope (Page or Frame).
43
+ * Needed for reload operations which are only available on Page.
44
+ */
45
+ function resolveOwnerPage(scope: Scope): Page {
46
+ return typeof (scope as any).page === 'function'
47
+ ? (scope as Frame).page()
48
+ : (scope as Page);
49
+ }
50
+
51
+ async function clickFirstVisible(
52
+ page: Scope,
53
+ selector: string,
54
+ label: string,
55
+ limit = 30,
56
+ ): Promise<boolean> {
57
+ const locator = page.locator(selector);
58
+ const count = Math.min(await locator.count().catch(() => 0), limit);
59
+
60
+ for (let index = 0; index < count; index++) {
61
+ const candidate = locator.nth(index);
62
+ if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) {
63
+ continue;
64
+ }
65
+
66
+ await candidate.click({ force: true }).catch((error: unknown) => {
67
+ logger.debug('Visible candidate click failed', {
68
+ label,
69
+ index,
70
+ error: error instanceof Error ? error.message : String(error),
71
+ });
72
+ throw error;
73
+ });
74
+ logger.debug('Clicked visible candidate', { label, index });
75
+ return true;
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ async function isFilterPanelVisible(page: Scope): Promise<boolean> {
82
+ return (
83
+ (await page
84
+ .locator(
85
+ [
86
+ '[id="supplement-container.viewSettings"].tii-panel-overlay--is-open',
87
+ '.tii-panel-overlay--is-open .filters-wrapper',
88
+ '.tii-similarity-supplement--view-settings .filters-wrapper',
89
+ '.filters-wrapper',
90
+ 'fieldset.filter-section',
91
+ '[data-px="FiltersBackToReportClicked"]',
92
+ ].join(', '),
93
+ )
94
+ .first()
95
+ .isVisible({ timeout: 1000 })
96
+ .catch(() => false)) ||
97
+ (await page
98
+ .locator(SELECTORS.filters.excludeBibliography)
99
+ .first()
100
+ .isVisible({ timeout: 1000 })
101
+ .catch(() => false))
102
+ );
103
+ }
104
+
105
+ async function clickFilterIconByDom(page: Scope): Promise<boolean> {
106
+ return page
107
+ .evaluate(() => {
108
+ const roots: (Document | ShadowRoot)[] = [document];
109
+ const seen = new Set<Element>();
110
+
111
+ for (let i = 0; i < roots.length; i++) {
112
+ const root = roots[i];
113
+ for (const el of Array.from(root.querySelectorAll('*'))) {
114
+ if (seen.has(el)) continue;
115
+ seen.add(el);
116
+ if (el.shadowRoot) roots.push(el.shadowRoot);
117
+ }
118
+ }
119
+
120
+ const isVisible = (el: Element): boolean => {
121
+ if (!(el instanceof HTMLElement)) return false;
122
+ const style = window.getComputedStyle(el);
123
+ const rect = el.getBoundingClientRect();
124
+ return (
125
+ style.visibility !== 'hidden' &&
126
+ style.display !== 'none' &&
127
+ rect.width > 0 &&
128
+ rect.height > 0
129
+ );
130
+ };
131
+
132
+ const clickableSelector =
133
+ 'button, a, tdl-button, tdl-labeled-button, tii-grn-button, [role="button"]';
134
+
135
+ const findClickable = (el: Element): HTMLElement | null => {
136
+ let current: Element | null = el;
137
+ const visited = new Set<Element>();
138
+
139
+ while (current && !visited.has(current)) {
140
+ visited.add(current);
141
+ if ((current as HTMLElement).matches?.(clickableSelector)) {
142
+ return current as HTMLElement;
143
+ }
144
+
145
+ const parent: Element | null = current.parentElement;
146
+ if (parent) {
147
+ current = parent;
148
+ continue;
149
+ }
150
+
151
+ const root = current.getRootNode();
152
+ current = root instanceof ShadowRoot ? root.host : null;
153
+ }
154
+
155
+ return null;
156
+ };
157
+
158
+ for (const root of roots) {
159
+ const candidates = Array.from(
160
+ root.querySelectorAll('tdl-icon, tii-grn-icon, [icon-name], [name], [aria-label], [title]'),
161
+ );
162
+
163
+ for (const candidate of candidates) {
164
+ if (!isVisible(candidate)) continue;
165
+ const text = [
166
+ candidate.getAttribute('icon-name'),
167
+ candidate.getAttribute('name'),
168
+ candidate.getAttribute('aria-label'),
169
+ candidate.getAttribute('title'),
170
+ candidate.textContent,
171
+ ]
172
+ .filter(Boolean)
173
+ .join(' ')
174
+ .toLowerCase();
175
+
176
+ if (!text.includes('filter') && !text.includes('setting')) {
177
+ continue;
178
+ }
179
+
180
+ const target = findClickable(candidate);
181
+ if (!target || !isVisible(target)) continue;
182
+ target.click();
183
+ return true;
184
+ }
185
+ }
186
+
187
+ return false;
188
+ })
189
+ .catch(() => false);
190
+ }
191
+
192
+ async function collectFilterDiagnostics(page: Scope): Promise<{
193
+ url: string;
194
+ bodySnippet: string;
195
+ controls: string[];
196
+ }> {
197
+ const visibleText = await readScopeVisibleDeepText(page);
198
+ const controls = await page
199
+ .evaluate(() => {
200
+ const roots: (Document | ShadowRoot)[] = [document];
201
+ const seen = new Set<Element>();
202
+
203
+ for (let i = 0; i < roots.length; i++) {
204
+ const root = roots[i];
205
+ for (const el of Array.from(root.querySelectorAll('*'))) {
206
+ if (seen.has(el)) continue;
207
+ seen.add(el);
208
+ if (el.shadowRoot) roots.push(el.shadowRoot);
209
+ }
210
+ }
211
+
212
+ const isVisible = (el: Element): boolean => {
213
+ if (!(el instanceof HTMLElement)) return false;
214
+ const style = window.getComputedStyle(el);
215
+ const rect = el.getBoundingClientRect();
216
+ return (
217
+ style.visibility !== 'hidden' &&
218
+ style.display !== 'none' &&
219
+ rect.width > 0 &&
220
+ rect.height > 0
221
+ );
222
+ };
223
+
224
+ const results: string[] = [];
225
+ for (const root of roots) {
226
+ const candidates = Array.from(
227
+ root.querySelectorAll(
228
+ 'button, a, input, tdl-button, tdl-labeled-button, tii-grn-button, [role="button"], [data-px], [with-data-px], [withdatapx], [aria-label], [title]',
229
+ ),
230
+ );
231
+ for (const el of candidates) {
232
+ if (!isVisible(el)) continue;
233
+ const text = [
234
+ (el as HTMLElement).innerText,
235
+ el.textContent,
236
+ el.getAttribute('aria-label'),
237
+ el.getAttribute('title'),
238
+ el.getAttribute('data-px'),
239
+ el.getAttribute('with-data-px'),
240
+ el.getAttribute('withdatapx'),
241
+ ]
242
+ .filter(Boolean)
243
+ .join(' ')
244
+ .replace(/\s+/g, ' ')
245
+ .trim();
246
+ if (!text) continue;
247
+ if (!/filter|setting|similarity|download|detail|report|apply/i.test(text)) {
248
+ continue;
249
+ }
250
+ results.push(`${el.tagName.toLowerCase()}: ${text.slice(0, 140)}`);
251
+ }
252
+ }
253
+ return Array.from(new Set(results)).slice(0, 20);
254
+ })
255
+ .catch(() => []);
256
+
257
+ return {
258
+ url: page.url(),
259
+ bodySnippet: visibleText.replace(/\s+/g, ' ').slice(0, 600),
260
+ controls,
261
+ };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Checkbox helper
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Read the current checked state of a Turnitin custom checkbox.
270
+ * Checks `with-checked` attribute AND the inner shadow DOM input element.
271
+ * Returns null if the checkbox is not visible.
272
+ */
273
+ async function readCheckboxState(
274
+ page: Scope,
275
+ selector: string,
276
+ ): Promise<boolean | null> {
277
+ const checkbox = page.locator(selector).first();
278
+ if (!(await checkbox.isVisible({ timeout: 3000 }).catch(() => false))) {
279
+ return null;
280
+ }
281
+
282
+ // Try `with-checked` attribute first (custom element outer)
283
+ const attr = await checkbox.getAttribute('with-checked').catch(() => null);
284
+ if (attr !== null) return attr === 'true';
285
+
286
+ // Try inner shadow-DOM input[type=checkbox]
287
+ const innerChecked = await checkbox.evaluate((el: any) => {
288
+ const inp =
289
+ el.shadowRoot?.querySelector('input[type="checkbox"]') ||
290
+ el.querySelector?.('input[type="checkbox"]');
291
+ return inp ? (inp as HTMLInputElement).checked : null;
292
+ }).catch(() => null);
293
+
294
+ if (innerChecked !== null) return innerChecked;
295
+
296
+ // Try aria-checked
297
+ const aria = await checkbox.getAttribute('aria-checked').catch(() => null);
298
+ if (aria !== null) return aria === 'true';
299
+
300
+ return false;
301
+ }
302
+
303
+ /**
304
+ * Set a Turnitin custom checkbox to the desired state.
305
+ *
306
+ * Strategy:
307
+ * 1. Read current state (with-checked attr + shadow DOM + aria-checked)
308
+ * 2. If state doesn't match desired, click
309
+ * 3. Wait briefly and read again to confirm
310
+ * 4. If still wrong after 2 attempts, try force-setting via JS evaluate
311
+ *
312
+ * Returns true if the checkbox was found (regardless of final state).
313
+ */
314
+ async function setCheckbox(
315
+ page: Scope,
316
+ selector: string,
317
+ enabled: boolean,
318
+ label = selector,
319
+ ): Promise<boolean> {
320
+ const checkbox = page.locator(selector).first();
321
+ if (!(await checkbox.isVisible({ timeout: 3000 }).catch(() => false))) {
322
+ return false;
323
+ }
324
+
325
+ for (let attempt = 0; attempt < 3; attempt++) {
326
+ const current = await readCheckboxState(page, selector);
327
+ if (current === null) return false; // vanished
328
+
329
+ if (current === enabled) {
330
+ logger.debug('Checkbox already in desired state', { label, enabled });
331
+ return true;
332
+ }
333
+
334
+ // Click to toggle
335
+ await checkbox.click({ force: true });
336
+ logger.debug('Clicked checkbox to toggle', {
337
+ label,
338
+ from: current,
339
+ to: enabled,
340
+ attempt: attempt + 1,
341
+ });
342
+ await page.waitForTimeout(600);
343
+
344
+ // Verify the click took effect
345
+ const afterClick = await readCheckboxState(page, selector);
346
+ if (afterClick === enabled) return true;
347
+
348
+ // State didn't change — try force-setting via JS on last attempt
349
+ if (attempt === 2) {
350
+ logger.warn('Checkbox click did not change state; trying JS force-set', {
351
+ label,
352
+ current: afterClick,
353
+ desired: enabled,
354
+ });
355
+ await checkbox.evaluate((el: any, desiredChecked: boolean) => {
356
+ const targets = [
357
+ el,
358
+ el.shadowRoot?.querySelector('input[type="checkbox"]'),
359
+ el.querySelector?.('input[type="checkbox"]'),
360
+ ].filter(Boolean);
361
+ for (const t of targets) {
362
+ if (t.tagName?.toLowerCase() === 'input') {
363
+ (t as HTMLInputElement).checked = desiredChecked;
364
+ t.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
365
+ t.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
366
+ } else {
367
+ t.setAttribute?.('with-checked', String(desiredChecked));
368
+ t.dispatchEvent?.(new Event('change', { bubbles: true, composed: true }));
369
+ }
370
+ }
371
+ }, enabled);
372
+ await page.waitForTimeout(400);
373
+ }
374
+ }
375
+
376
+ return true;
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Threshold input helper
381
+ // ---------------------------------------------------------------------------
382
+
383
+ async function setSmallMatchThreshold(
384
+ page: Scope,
385
+ threshold: number,
386
+ ): Promise<boolean> {
387
+ const value = String(threshold);
388
+ const input = page.locator(SELECTORS.filters.smallMatchesInput).first();
389
+ if (!(await input.isVisible({ timeout: 5000 }).catch(() => false)))
390
+ return false;
391
+
392
+ await input.evaluate((el: any, nextValue: string) => {
393
+ const candidates = [
394
+ el,
395
+ el.shadowRoot?.querySelector('input'),
396
+ el.querySelector?.('input'),
397
+ ].filter(Boolean);
398
+
399
+ for (const candidate of candidates) {
400
+ candidate.value = nextValue;
401
+ candidate.setAttribute?.('with-value', nextValue);
402
+ candidate.dispatchEvent(
403
+ new Event('input', { bubbles: true, composed: true }),
404
+ );
405
+ candidate.dispatchEvent(
406
+ new Event('change', { bubbles: true, composed: true }),
407
+ );
408
+ }
409
+ }, value);
410
+
411
+ await page.waitForTimeout(500);
412
+ return true;
413
+ }
414
+
415
+ async function openFiltersPanel(page: Scope): Promise<boolean> {
416
+ const deadline = Date.now() + 45_000;
417
+ let attempt = 0;
418
+
419
+ while (Date.now() < deadline) {
420
+ attempt++;
421
+
422
+ if (await isFilterPanelVisible(page)) {
423
+ return true;
424
+ }
425
+
426
+ if (attempt === 1 || attempt % 4 === 0) {
427
+ const clickedSimilarity =
428
+ (await clickFirstVisible(
429
+ page,
430
+ SELECTORS.filters.similarityTab,
431
+ 'similarity-tab',
432
+ 20,
433
+ )) ||
434
+ (await deepClickByText(page, ['similarity report', 'similarity']).catch(
435
+ () => false,
436
+ ));
437
+ if (clickedSimilarity) {
438
+ await page.waitForTimeout(1500);
439
+ if (await isFilterPanelVisible(page)) return true;
440
+ }
441
+ }
442
+
443
+ const clickedFilterButton =
444
+ (await clickFirstVisible(
445
+ page,
446
+ SELECTORS.filters.filterButton,
447
+ 'filter-button',
448
+ 30,
449
+ )) ||
450
+ (await clickFilterIconByDom(page)) ||
451
+ (await deepClickByText(page, ['filters', 'filter']).catch(() => false));
452
+
453
+ if (clickedFilterButton) {
454
+ await page.waitForTimeout(1800);
455
+ if (await isFilterPanelVisible(page)) {
456
+ return true;
457
+ }
458
+ }
459
+
460
+ if (attempt % 5 === 0) {
461
+ const diagnostics = await collectFilterDiagnostics(page);
462
+ logger.debug('Still waiting for similarity filter panel', {
463
+ attempt,
464
+ url: diagnostics.url,
465
+ controls: diagnostics.controls.slice(0, 8),
466
+ bodySnippet: diagnostics.bodySnippet,
467
+ });
468
+ }
469
+
470
+ await page.waitForTimeout(2500);
471
+ }
472
+
473
+ logger.warn('Filter panel did not open before timeout', {
474
+ ...(await collectFilterDiagnostics(page)),
475
+ });
476
+ return false;
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Filter validation
481
+ // ---------------------------------------------------------------------------
482
+
483
+ /**
484
+ * Verify the current state of all filter checkboxes matches the desired state.
485
+ * Returns a list of filter names that are NOT in the correct state.
486
+ */
487
+ async function verifyFilterStates(
488
+ page: Scope,
489
+ filters: FilterOptions,
490
+ ): Promise<string[]> {
491
+ const mismatches: string[] = [];
492
+
493
+ const checks: Array<{ key: keyof FilterOptions; selector: string; label: string }> = [
494
+ { key: 'excludeBibliography', selector: SELECTORS.filters.excludeBibliography, label: 'Bibliography' },
495
+ { key: 'excludeQuotes', selector: SELECTORS.filters.excludeQuotes, label: 'Quotes' },
496
+ { key: 'excludeCitations', selector: SELECTORS.filters.excludeCitations, label: 'Citations' },
497
+ { key: 'excludeSmallMatches', selector: SELECTORS.filters.excludeSmallMatches, label: 'Small Matches' },
498
+ ];
499
+
500
+ for (const { key, selector, label } of checks) {
501
+ const desired = Boolean(filters[key]);
502
+ if (!desired) continue; // We only validate that active filters ARE enabled
503
+
504
+ const current = await readCheckboxState(page, selector);
505
+ if (current === null) {
506
+ // Checkbox not visible — may not be applicable for this report
507
+ logger.warn(`Filter validation: '${label}' checkbox not visible, cannot verify`, { desired });
508
+ continue;
509
+ }
510
+
511
+ if (current !== desired) {
512
+ mismatches.push(label);
513
+ logger.warn(`Filter validation FAILED: '${label}' is ${current} but should be ${desired}`);
514
+ } else {
515
+ logger.info(`Filter validation OK: '${label}' is ${current}`);
516
+ }
517
+ }
518
+
519
+ return mismatches;
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Public API
524
+ // ---------------------------------------------------------------------------
525
+
526
+ /**
527
+ * Apply similarity report filters in the Turnitin viewer.
528
+ *
529
+ * ALL filters can be toggled ON and OFF:
530
+ * - excludeBibliography (true = check, false = uncheck)
531
+ * - excludeQuotes (true = check, false = uncheck)
532
+ * - excludeCitations (true = check, false = uncheck)
533
+ * - excludeSmallMatches (true = check and set threshold, false = uncheck)
534
+ *
535
+ * Steps:
536
+ * 1. Click Similarity tab
537
+ * 2. Click Filters button (with up to 3 page-refresh retries)
538
+ * 3. For each filter: read current state, toggle if needed (with verify loop)
539
+ * 4. For small matches: if enabling, set threshold (clamped 1–40)
540
+ * 5. Click Apply Filters if enabled
541
+ * 6. Verify all active filters are applied — retry once if not
542
+ * 7. Click Back to Similarity Report
543
+ */
544
+ export async function applyFilters(
545
+ page: Scope,
546
+ filters: FilterOptions,
547
+ ): Promise<void> {
548
+ const ownerPage = resolveOwnerPage(page);
549
+ const MAX_REFRESH_ATTEMPTS = 3;
550
+ const activeFilters = hasActiveFilters(filters);
551
+
552
+ for (let attempt = 1; attempt <= MAX_REFRESH_ATTEMPTS; attempt++) {
553
+ // 1-2. Open Similarity tab and Filters panel.
554
+ const opened = await openFiltersPanel(page);
555
+ if (!opened) {
556
+ if (attempt < MAX_REFRESH_ATTEMPTS) {
557
+ logger.warn(
558
+ `Filter panel not visible (attempt ${attempt}/${MAX_REFRESH_ATTEMPTS}); ` +
559
+ 'refreshing viewer page and retrying',
560
+ );
561
+ await ownerPage
562
+ .reload({ waitUntil: 'domcontentloaded', timeout: 30000 })
563
+ .catch(() => {});
564
+ await ownerPage.waitForTimeout(8000);
565
+ continue;
566
+ }
567
+
568
+ const diagnostics = await collectFilterDiagnostics(page);
569
+ logger.error('Similarity filter panel could not be opened', diagnostics);
570
+ throw new Error(
571
+ `Similarity filter panel could not be opened after ${MAX_REFRESH_ATTEMPTS} ` +
572
+ 'page refresh attempts; filters were NOT applied. ' +
573
+ `Visible controls: ${diagnostics.controls.slice(0, 6).join(' | ') || 'none'}`,
574
+ );
575
+ }
576
+
577
+ // 3. Set each filter checkbox (handles both ON and OFF) with verification
578
+ await setCheckbox(
579
+ page,
580
+ SELECTORS.filters.excludeBibliography,
581
+ Boolean(filters.excludeBibliography),
582
+ 'Bibliography',
583
+ );
584
+ await setCheckbox(
585
+ page,
586
+ SELECTORS.filters.excludeQuotes,
587
+ Boolean(filters.excludeQuotes),
588
+ 'Quotes',
589
+ );
590
+ await setCheckbox(
591
+ page,
592
+ SELECTORS.filters.excludeCitations,
593
+ Boolean(filters.excludeCitations),
594
+ 'Citations',
595
+ );
596
+
597
+ // 4. Small matches filter
598
+ const smallMatchEnabled = Boolean(filters.excludeSmallMatches);
599
+ const smallMatchesChanged = await setCheckbox(
600
+ page,
601
+ SELECTORS.filters.excludeSmallMatches,
602
+ smallMatchEnabled,
603
+ 'Small Matches',
604
+ );
605
+
606
+ if (smallMatchesChanged && smallMatchEnabled && filters.smallMatchThreshold != null) {
607
+ const clamped = Math.min(40, Math.max(1, Math.round(filters.smallMatchThreshold)));
608
+ const thresholdSet = await setSmallMatchThreshold(page, clamped);
609
+ if (!thresholdSet) {
610
+ logger.warn(
611
+ 'Exclude small matches was enabled, but the threshold input was not found',
612
+ );
613
+ }
614
+ }
615
+
616
+ // 5. Click Apply Filters — MUST succeed when filters are active.
617
+ const clickedApply = await clickFirstVisible(
618
+ page,
619
+ SELECTORS.filters.applyFilters,
620
+ 'apply-filters',
621
+ 20,
622
+ );
623
+ if (clickedApply) {
624
+ // Wait for the filter to take effect and the report to re-render.
625
+ await page.waitForTimeout(3000);
626
+ logger.info('Apply Filters button clicked successfully');
627
+
628
+ // 6. Click Back to Similarity Report
629
+ if (
630
+ await clickFirstVisible(
631
+ page,
632
+ SELECTORS.filters.backToReport,
633
+ 'back-to-report',
634
+ 20,
635
+ )
636
+ ) {
637
+ await page.waitForTimeout(1500);
638
+ }
639
+
640
+ logger.info('Filters applied', { filters });
641
+ return;
642
+ }
643
+
644
+ if (!activeFilters) {
645
+ if (
646
+ await clickFirstVisible(
647
+ page,
648
+ SELECTORS.filters.backToReport,
649
+ 'back-to-report',
650
+ 20,
651
+ )
652
+ ) {
653
+ await page.waitForTimeout(1500);
654
+ }
655
+ logger.warn(
656
+ 'Apply Filters button was not visible, but no filters are active; continuing',
657
+ );
658
+ return;
659
+ }
660
+
661
+ if (attempt < MAX_REFRESH_ATTEMPTS) {
662
+ logger.warn(
663
+ `Apply Filters button was not visible after opening the filter panel ` +
664
+ `(attempt ${attempt}/${MAX_REFRESH_ATTEMPTS}); refreshing viewer page and retrying`,
665
+ );
666
+ await ownerPage
667
+ .reload({ waitUntil: 'domcontentloaded', timeout: 30000 })
668
+ .catch(() => {});
669
+ await ownerPage.waitForTimeout(8000);
670
+ continue;
671
+ }
672
+
673
+ throw new Error(
674
+ 'Apply Filters button was not visible after opening the filter panel ' +
675
+ `and refreshing the viewer ${MAX_REFRESH_ATTEMPTS} times, but filters ` +
676
+ 'are active. Aborting to prevent an unfiltered report download.',
677
+ );
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Validate that all active filters are currently applied in the viewer.
683
+ * Called after applyFilters() and before downloading the PDF.
684
+ * Returns the list of filter names that failed validation.
685
+ *
686
+ * @param retryApply - if true and validation fails, re-opens filters and retries
687
+ */
688
+ export async function validateFilters(
689
+ page: Scope,
690
+ filters: FilterOptions,
691
+ retryApply = true,
692
+ ): Promise<void> {
693
+ if (!hasActiveFilters(filters)) return; // Nothing to validate
694
+
695
+ // Re-open the filter panel to read current state
696
+ const opened = await openFiltersPanel(page);
697
+ if (!opened) {
698
+ logger.warn('Cannot validate filters: filter panel could not be re-opened');
699
+ return;
700
+ }
701
+
702
+ const mismatches = await verifyFilterStates(page, filters);
703
+
704
+ if (mismatches.length === 0) {
705
+ logger.info('Filter validation passed — all active filters confirmed', { filters });
706
+ // Close the panel
707
+ if (
708
+ await clickFirstVisible(
709
+ page,
710
+ SELECTORS.filters.backToReport,
711
+ 'back-to-report-validation',
712
+ 20,
713
+ )
714
+ ) {
715
+ await page.waitForTimeout(1000);
716
+ }
717
+ return;
718
+ }
719
+
720
+ // Some filters are not in the correct state
721
+ logger.warn('Filter validation found mismatches', { mismatches });
722
+
723
+ if (retryApply) {
724
+ logger.info('Re-applying filters due to validation failure', { mismatches });
725
+
726
+ // Re-apply the mismatched filters
727
+ for (const label of mismatches) {
728
+ if (label === 'Bibliography') {
729
+ await setCheckbox(page, SELECTORS.filters.excludeBibliography, true, 'Bibliography');
730
+ } else if (label === 'Quotes') {
731
+ await setCheckbox(page, SELECTORS.filters.excludeQuotes, true, 'Quotes');
732
+ } else if (label === 'Citations') {
733
+ await setCheckbox(page, SELECTORS.filters.excludeCitations, true, 'Citations');
734
+ } else if (label === 'Small Matches') {
735
+ await setCheckbox(page, SELECTORS.filters.excludeSmallMatches, true, 'Small Matches');
736
+ }
737
+ }
738
+
739
+ // Click Apply again
740
+ if (
741
+ await clickFirstVisible(
742
+ page,
743
+ SELECTORS.filters.applyFilters,
744
+ 'apply-filters-validation',
745
+ 20,
746
+ )
747
+ ) {
748
+ await page.waitForTimeout(3000);
749
+ }
750
+
751
+ // Final verification — no more retries
752
+ const finalMismatches = await verifyFilterStates(page, filters);
753
+ if (finalMismatches.length > 0) {
754
+ throw new Error(
755
+ `Filters could not be verified after retry. The following filters are NOT active: ` +
756
+ finalMismatches.join(', ') +
757
+ '. Download aborted to prevent unfiltered report.',
758
+ );
759
+ }
760
+ logger.info('Filter validation passed after retry', { filters });
761
+ } else {
762
+ throw new Error(
763
+ `Filter validation failed. The following filters are NOT active: ` +
764
+ mismatches.join(', ') +
765
+ '. Download aborted to prevent unfiltered report.',
766
+ );
767
+ }
768
+
769
+ // Close the panel
770
+ if (
771
+ await clickFirstVisible(
772
+ page,
773
+ SELECTORS.filters.backToReport,
774
+ 'back-to-report-validation',
775
+ 20,
776
+ )
777
+ ) {
778
+ await page.waitForTimeout(1000);
779
+ }
780
+ }
src/engine/steps/login.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ EULA_SELECTORS,
5
+ EULA_BODY_REGEX,
6
+ EULA_DEEP_CLICK_TEXTS,
7
+ SELECTORS,
8
+ clickVisibleCheckbox,
9
+ deepClickByText,
10
+ } from '../selectors';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // EULA acceptance helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type Scope = Page | Frame;
17
+
18
+ async function acceptEulaInScope(scope: Scope): Promise<boolean> {
19
+ for (const selector of EULA_SELECTORS) {
20
+ const button = scope.locator(selector).first();
21
+ if (await button.isVisible({ timeout: 1500 }).catch(() => false)) {
22
+ await button.click({ force: true });
23
+ await scope.waitForTimeout(2500).catch(() => {});
24
+ return true;
25
+ }
26
+ }
27
+
28
+ const bodyText = await scope
29
+ .locator('body')
30
+ .innerText({ timeout: 1000 })
31
+ .catch(() => '');
32
+ if (EULA_BODY_REGEX.test(bodyText)) {
33
+ await clickVisibleCheckbox(scope);
34
+ if (await deepClickByText(scope, EULA_DEEP_CLICK_TEXTS)) {
35
+ await scope.waitForTimeout(2500).catch(() => {});
36
+ return true;
37
+ }
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ export async function acceptEulaEverywhere(page: Page): Promise<boolean> {
44
+ let accepted = await acceptEulaInScope(page);
45
+ for (const frame of page.frames()) {
46
+ accepted =
47
+ (await acceptEulaInScope(frame).catch(() => false)) || accepted;
48
+ }
49
+ return accepted;
50
+ }
51
+
52
+ export async function acceptEulaUntilSettled(
53
+ page: Page,
54
+ timeoutMs = 15000,
55
+ ): Promise<boolean> {
56
+ const deadline = Date.now() + timeoutMs;
57
+ let accepted = false;
58
+ // BUG-8 FIX: Break early when no EULA detected after 2 consecutive checks.
59
+ // Previously the loop always ran for the full timeoutMs (15s), wasting ~30s
60
+ // per job in the common case where no EULA is shown at all.
61
+ let consecutiveNoEula = 0;
62
+ while (Date.now() < deadline) {
63
+ const foundEula = await acceptEulaEverywhere(page);
64
+ accepted = foundEula || accepted;
65
+ if (!foundEula) {
66
+ consecutiveNoEula++;
67
+ if (consecutiveNoEula >= 2) break; // No EULA on 2 consecutive checks — stop
68
+ } else {
69
+ consecutiveNoEula = 0; // Reset counter when EULA was found and accepted
70
+ }
71
+ await page.waitForTimeout(1200);
72
+ }
73
+ return accepted;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Login
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const DEFAULT_TARGET_URL =
81
+ 'https://www.turnitin.com/login_page.asp?lang=en_us';
82
+ const STUDENT_HOME_URL =
83
+ 'https://www.turnitin.com/s_home.asp?lang=en_us';
84
+
85
+ /**
86
+ * Log in to Turnitin with email/password, accept EULA if shown,
87
+ * handle redirect to user-type page, and optionally save storage state.
88
+ */
89
+ export async function loginToTurnitin(
90
+ page: Page,
91
+ email: string,
92
+ password: string,
93
+ storageStatePath?: string,
94
+ targetUrl = DEFAULT_TARGET_URL,
95
+ ): Promise<void> {
96
+ logger.info('Navigating to Turnitin login page');
97
+ let emailInputVisible = false;
98
+ for (let attempt = 1; attempt <= 4; attempt++) {
99
+ await page.goto(targetUrl || DEFAULT_TARGET_URL, {
100
+ waitUntil: 'domcontentloaded',
101
+ timeout: 60000,
102
+ });
103
+
104
+ emailInputVisible = await page
105
+ .locator(SELECTORS.login.emailInput)
106
+ .isVisible({ timeout: 15000 })
107
+ .catch(() => false);
108
+
109
+ if (emailInputVisible) break;
110
+
111
+ await acceptEulaEverywhere(page);
112
+ const bodyText = await page
113
+ .locator('body')
114
+ .innerText({ timeout: 3000 })
115
+ .catch(() => '');
116
+ const currentUrl = page.url();
117
+ if (
118
+ !currentUrl.includes('login_page.asp') ||
119
+ /logout|student|class portfolio/i.test(bodyText)
120
+ ) {
121
+ logger.info('Login form not shown; continuing with existing Turnitin session', {
122
+ url: currentUrl,
123
+ });
124
+ return;
125
+ }
126
+
127
+ if (/403 ERROR|Request blocked|could not be satisfied/i.test(bodyText)) {
128
+ logger.warn('Turnitin login page was temporarily blocked; retrying', {
129
+ attempt,
130
+ });
131
+ await page.waitForTimeout(2500 + attempt * 1500);
132
+ continue;
133
+ }
134
+
135
+ await page.waitForTimeout(1500);
136
+ }
137
+
138
+ if (!emailInputVisible) {
139
+ throw new Error(
140
+ `Turnitin login form was not visible (URL: ${page.url()})`,
141
+ );
142
+ }
143
+
144
+ await page.fill(SELECTORS.login.emailInput, email);
145
+ await page.fill(SELECTORS.login.passwordInput, password);
146
+
147
+ await Promise.all([
148
+ page
149
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
150
+ .catch(() => null),
151
+ page.click(SELECTORS.login.submitButton),
152
+ ]);
153
+
154
+ await page
155
+ .waitForLoadState('domcontentloaded', { timeout: 30000 })
156
+ .catch(() => {});
157
+
158
+ // Check if we are still on the login page (i.e. login failed)
159
+ const currentUrl = page.url();
160
+ if (currentUrl.includes('login_page.asp')) {
161
+ const errorText = await page
162
+ .locator('.error, .error-message, #error_message_box, #error_box, td.errorText, .errorText')
163
+ .first()
164
+ .innerText({ timeout: 2000 })
165
+ .catch(() => '');
166
+
167
+ const cleanMsg = errorText ? errorText.trim().replace(/\s+/g, ' ') : 'Invalid email or password';
168
+ throw new Error(`Login failed on Turnitin: ${cleanMsg} (URL: ${currentUrl})`);
169
+ }
170
+
171
+ await acceptEulaEverywhere(page);
172
+
173
+ // Handle redirect to user type page
174
+ if (page.url().includes('user_user_type.asp')) {
175
+ await page.goto(STUDENT_HOME_URL, {
176
+ waitUntil: 'domcontentloaded',
177
+ timeout: 60000,
178
+ });
179
+ await acceptEulaEverywhere(page);
180
+ }
181
+
182
+ // Double check if we got kicked back to login page
183
+ if (page.url().includes('login_page.asp')) {
184
+ throw new Error(`Login failed on Turnitin: redirected back to login page (URL: ${page.url()})`);
185
+ }
186
+
187
+ // Save storage state if requested
188
+ if (storageStatePath) {
189
+ await page.context().storageState({ path: storageStatePath });
190
+ logger.info('Storage state saved', { path: storageStatePath });
191
+ }
192
+
193
+ logger.info('Login successful', { url: page.url() });
194
+ }
src/engine/steps/navigate.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import { SELECTORS } from '../selectors';
4
+ import { acceptEulaEverywhere, acceptEulaUntilSettled } from './login';
5
+
6
+ /**
7
+ * Navigate to the target assignment within a class.
8
+ *
9
+ * 1. Find and click the class by title
10
+ * 2. Find and click the assignment (by title or first available)
11
+ * 3. Return the assignment launch URL for later refresh
12
+ */
13
+ export async function navigateToAssignment(
14
+ page: Page,
15
+ classTitle: string,
16
+ assignmentTitle?: string | null,
17
+ ): Promise<string> {
18
+ // ---- Open class ----
19
+ logger.info('Opening class', { classTitle });
20
+
21
+ if (!classTitle || classTitle.trim() === '') {
22
+ throw new Error('Class title is empty - cannot navigate to assignment.');
23
+ }
24
+
25
+ const escapedTitle = classTitle.replace(/"/g, '\\"');
26
+ const exact = page.locator(`a[title="${escapedTitle}"]`).first();
27
+ const fallback = page
28
+ .locator(SELECTORS.class.classNameLink)
29
+ .filter({ hasText: classTitle })
30
+ .first();
31
+
32
+ const link =
33
+ (await exact.isVisible({ timeout: 5000 }).catch(() => false))
34
+ ? exact
35
+ : fallback;
36
+
37
+ await link.waitFor({ state: 'visible', timeout: 30000 });
38
+ await Promise.all([
39
+ page
40
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
41
+ .catch(() => null),
42
+ link.click({ force: true }),
43
+ ]);
44
+ await page.waitForTimeout(1500);
45
+ await acceptEulaEverywhere(page);
46
+
47
+ const classUrl = page.url();
48
+ logger.info('Class page loaded', { classUrl });
49
+
50
+ // ---- Open assignment ----
51
+ // Ensure we're on the class page
52
+ if (page.url() !== classUrl) {
53
+ await page.goto(classUrl, {
54
+ waitUntil: 'domcontentloaded',
55
+ timeout: 60000,
56
+ });
57
+ await page.waitForTimeout(1500);
58
+ }
59
+ await acceptEulaUntilSettled(page, 5000);
60
+
61
+ let assignmentButton;
62
+ if (assignmentTitle) {
63
+ logger.info('Looking for assignment', { assignmentTitle });
64
+ assignmentButton = page
65
+ .locator(SELECTORS.assignment.assignmentRow)
66
+ .filter({ hasText: assignmentTitle })
67
+ .locator(SELECTORS.assignment.openButton)
68
+ .first();
69
+ } else {
70
+ logger.info('Looking for first available assignment');
71
+ assignmentButton = page
72
+ .locator(SELECTORS.assignment.allOpenButtons)
73
+ .first();
74
+ }
75
+
76
+ await assignmentButton.waitFor({ state: 'visible', timeout: 30000 });
77
+ await Promise.all([
78
+ page
79
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
80
+ .catch(() => null),
81
+ assignmentButton.click({ force: true }),
82
+ ]);
83
+ await page.waitForTimeout(5000);
84
+ await acceptEulaUntilSettled(page, 7000);
85
+
86
+ const assignmentLaunchUrl = page.url();
87
+ logger.info('Assignment page loaded', { assignmentLaunchUrl });
88
+
89
+ return assignmentLaunchUrl;
90
+ }
src/engine/steps/quota-detect.ts ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ QUOTA_CONFIRM_LABELS,
5
+ SELECTORS,
6
+ readScopeDeepText,
7
+ readScopeVisibleDeepText,
8
+ } from '../selectors';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface QuotaLimitResult {
15
+ limited: true;
16
+ limit: number;
17
+ retryText: string;
18
+ message: string;
19
+ }
20
+
21
+ export interface QuotaWarningResult {
22
+ warning: true;
23
+ message: string;
24
+ }
25
+
26
+ export interface QuotaCheckResult {
27
+ quotaLimited: boolean;
28
+ limit: number | null;
29
+ retryText: string | null;
30
+ message: string | null;
31
+ warning: string | null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Parsers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function parseSubmissionQuotaLimit(text: string): QuotaLimitResult | null {
39
+ const normalized = String(text || '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
42
+ const match = normalized.match(
43
+ /You have reached your limit of\s+(\d+)\s+submissions\.\s+You can submit again\s+(.+?)(?:\.|$)/i,
44
+ );
45
+ if (!match) return null;
46
+
47
+ return {
48
+ limited: true,
49
+ limit: Number(match[1]),
50
+ retryText: match[2],
51
+ message: match[0],
52
+ };
53
+ }
54
+
55
+ function parseSubmissionQuotaWarning(
56
+ text: string,
57
+ ): QuotaWarningResult | null {
58
+ const normalized = String(text || '')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+ const patterns = [
62
+ /(?:only\s+)?(?:1|one)\s+submissions?\s+remaining/i,
63
+ /(?:only\s+)?(?:1|one)\s+remaining\s+submissions?/i,
64
+ /(?:only\s+)?(?:1|one)\s+more\s+submissions?\s+left/i,
65
+ /final\s+submission/i,
66
+ /last\s+submission/i,
67
+ /remaining\s+submissions?/i,
68
+ /limited\s+number\s+of\s+submissions/i,
69
+ ];
70
+
71
+ if (!patterns.some((p) => p.test(normalized))) return null;
72
+
73
+ return {
74
+ warning: true,
75
+ message: normalized.slice(0, 500),
76
+ };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Scope helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ type Scope = Page | Frame;
84
+
85
+ const QUOTA_ACTION_SELECTOR = [
86
+ 'button',
87
+ 'input[type="button"]',
88
+ 'input[type="submit"]',
89
+ 'tdl-button',
90
+ 'tii-grn-button',
91
+ '[role="button"]',
92
+ '[slot="accept-button"]',
93
+ '[part*="button"]',
94
+ '[data-px*="Continue"]',
95
+ '[with-data-px*="Continue"]',
96
+ ].join(', ');
97
+
98
+ function getScopes(page: Page): Scope[] {
99
+ return [
100
+ page,
101
+ ...page.frames().filter((f) => !f.url().includes('cookie-shim')),
102
+ ];
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Public API
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Detect whether the page shows a hard quota limit (all submissions used).
111
+ */
112
+ export async function detectQuotaLimit(
113
+ page: Page,
114
+ ): Promise<QuotaLimitResult | null> {
115
+ for (const scope of getScopes(page)) {
116
+ const text = await readScopeDeepText(scope);
117
+ const result = parseSubmissionQuotaLimit(text);
118
+ if (result) return result;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Detect whether the page shows a quota warning (e.g. 1 submission remaining).
125
+ */
126
+ export async function detectQuotaWarning(
127
+ page: Page,
128
+ ): Promise<QuotaWarningResult | null> {
129
+ for (const scope of getScopes(page)) {
130
+ const text = await readScopeVisibleDeepText(scope);
131
+ const result = parseSubmissionQuotaWarning(text);
132
+ if (result) return result;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Attempt to confirm/dismiss a quota warning popup by clicking a continue
139
+ * button. Returns true if a warning was found and confirmed.
140
+ *
141
+ * Improvements:
142
+ * - Skips if the warning text is just background info (no active dialog/modal)
143
+ * - Uses dialog.close() as a nuclear fallback to guarantee modal closure
144
+ */
145
+ export async function confirmQuotaWarning(
146
+ page: Page,
147
+ ): Promise<boolean> {
148
+ for (const scope of getScopes(page)) {
149
+ const text = await readScopeVisibleDeepText(scope);
150
+ const warning = parseSubmissionQuotaWarning(text);
151
+ if (!warning) continue;
152
+
153
+ // Check if there is ACTUALLY an interactive modal/dialog open.
154
+ // Without this check, background informational text ("You have 1 more
155
+ // submission left") keeps triggering the handler even when no dialog exists.
156
+ const hasActionableDialog = await scope
157
+ .evaluate(() => {
158
+ const roots: (Document | ShadowRoot)[] = [document];
159
+ const seen = new Set<Element>();
160
+ for (let i = 0; i < roots.length; i++) {
161
+ const root = roots[i];
162
+ for (const el of Array.from(root.querySelectorAll('*'))) {
163
+ if (seen.has(el)) continue;
164
+ seen.add(el);
165
+ if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
166
+ }
167
+ }
168
+ const isVisible = (el: Element): boolean => {
169
+ if (!(el instanceof HTMLElement)) return false;
170
+ const s = window.getComputedStyle(el);
171
+ const r = el.getBoundingClientRect();
172
+ return s.visibility !== 'hidden' && s.display !== 'none' && r.width > 0 && r.height > 0;
173
+ };
174
+ const hasText = (el: Element, pattern: RegExp): boolean =>
175
+ pattern.test((el as HTMLElement).innerText || el.textContent || '');
176
+
177
+ for (const root of roots) {
178
+ // Native <dialog open>
179
+ for (const d of Array.from(root.querySelectorAll('dialog[open]'))) {
180
+ if (isVisible(d) && hasText(d, /more submission|file upload|continue/i)) return true;
181
+ }
182
+ // Custom modal/overlay elements
183
+ for (const el of Array.from(
184
+ root.querySelectorAll('[role="dialog"], [aria-modal="true"], tii-modal, .tii-modal, [id*="modal"]'),
185
+ )) {
186
+ if (isVisible(el) && hasText(el, /more submission|file upload|continue/i)) return true;
187
+ }
188
+ // tii-grn-button with "Continue to File Upload" visible => quota dialog must be open
189
+ for (const el of Array.from(root.querySelectorAll('tii-grn-button'))) {
190
+ if (!isVisible(el)) continue;
191
+ const t = ((el as HTMLElement).innerText || el.textContent || '').toLowerCase();
192
+ if (t.includes('continue to file upload')) return true;
193
+ }
194
+ }
195
+ return false;
196
+ })
197
+ .catch(() => false);
198
+
199
+ if (!hasActionableDialog) {
200
+ // Only background informational text — no interactive dialog to dismiss.
201
+ logger.info('Quota warning text is background-only (no active dialog); skipping dismissal');
202
+ continue;
203
+ }
204
+
205
+ logger.info('Submission quota warning dialog detected; attempting to dismiss', {
206
+ message: warning.message,
207
+ });
208
+
209
+ const clicked = await clickQuotaWarningContinue(scope);
210
+ if (!clicked) {
211
+ logger.warn('Submission quota warning continue button was not found');
212
+ }
213
+
214
+ await page.waitForTimeout(1500);
215
+
216
+ // If warning is still visible, try coordinate-based click
217
+ const stillVisible = parseSubmissionQuotaWarning(
218
+ await readScopeVisibleDeepText(scope),
219
+ );
220
+ if (stillVisible) {
221
+ await clickQuotaWarningContinueByCoordinates(page, scope);
222
+ await page.waitForTimeout(1500);
223
+ }
224
+
225
+ // Nuclear fallback: forcibly close any open dialog with quota/upload text
226
+ const stillVisibleAfter = parseSubmissionQuotaWarning(
227
+ await readScopeVisibleDeepText(scope),
228
+ );
229
+ if (stillVisibleAfter) {
230
+ logger.warn('Quota warning still visible; using dialog.close() nuclear fallback');
231
+ await scope
232
+ .evaluate(() => {
233
+ const roots: (Document | ShadowRoot)[] = [document];
234
+ const seen = new Set<Element>();
235
+ for (let i = 0; i < roots.length; i++) {
236
+ const root = roots[i];
237
+ for (const el of Array.from(root.querySelectorAll('*'))) {
238
+ if (seen.has(el)) continue;
239
+ seen.add(el);
240
+ if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
241
+ }
242
+ }
243
+ for (const root of roots) {
244
+ for (const d of Array.from(root.querySelectorAll('dialog[open], dialog'))) {
245
+ if (d instanceof HTMLDialogElement) {
246
+ const t = (d as HTMLElement).innerText || d.textContent || '';
247
+ if (/more submission|file upload|continue/i.test(t)) {
248
+ d.close();
249
+ }
250
+ }
251
+ }
252
+ }
253
+ })
254
+ .catch(() => {});
255
+ await page.waitForTimeout(1000);
256
+ }
257
+
258
+ return true;
259
+ }
260
+
261
+ return false;
262
+ }
263
+
264
+ /**
265
+ * Run a complete quota check: detect limit and/or warning.
266
+ */
267
+ export async function runQuotaCheck(
268
+ page: Page,
269
+ ): Promise<QuotaCheckResult> {
270
+ const limit = await detectQuotaLimit(page);
271
+ if (limit) {
272
+ return {
273
+ quotaLimited: true,
274
+ limit: limit.limit,
275
+ retryText: limit.retryText,
276
+ message: limit.message,
277
+ warning: null,
278
+ };
279
+ }
280
+
281
+ const warning = await detectQuotaWarning(page);
282
+ return {
283
+ quotaLimited: false,
284
+ limit: null,
285
+ retryText: null,
286
+ message: null,
287
+ warning: warning?.message || null,
288
+ };
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Internal: click quota warning continue buttons
293
+ // ---------------------------------------------------------------------------
294
+
295
+ async function clickQuotaWarningContinue(
296
+ scope: Scope,
297
+ ): Promise<boolean> {
298
+ // Try each preferred label in order
299
+ for (const label of QUOTA_CONFIRM_LABELS) {
300
+ const escaped = label.replace(/"/g, '\\"');
301
+ const button = scope
302
+ .locator(
303
+ `tii-grn-button:has-text("${escaped}"), button:has-text("${escaped}"), tdl-button:has-text("${escaped}"), [role="button"]:has-text("${escaped}")`,
304
+ )
305
+ .last();
306
+ if (await button.isVisible({ timeout: 1000 }).catch(() => false)) {
307
+ await button.click({ force: true });
308
+ return true;
309
+ }
310
+
311
+ const textTarget = scope.getByText(label, { exact: false }).last();
312
+ if (
313
+ await textTarget.isVisible({ timeout: 1000 }).catch(() => false)
314
+ ) {
315
+ await textTarget.click({ force: true });
316
+ return true;
317
+ }
318
+ }
319
+
320
+ // Try filled action slot button
321
+ const filledAction = scope
322
+ .locator(SELECTORS.quota.warningConfirmButton)
323
+ .last();
324
+ if (
325
+ await filledAction.isVisible({ timeout: 1000 }).catch(() => false)
326
+ ) {
327
+ await filledAction.click({ force: true });
328
+ return true;
329
+ }
330
+
331
+ // Deep shadow DOM scan fallback. Turnitin often renders the visible label in
332
+ // a shadow/slot child, while the actual clickable target is the host element.
333
+ return scope
334
+ .evaluate(
335
+ ({
336
+ labels,
337
+ selector,
338
+ }: {
339
+ labels: string[];
340
+ selector: string;
341
+ }) => {
342
+ const roots: (Document | ShadowRoot)[] = [document];
343
+ const seen = new Set<Element>();
344
+
345
+ for (let i = 0; i < roots.length; i++) {
346
+ const root = roots[i];
347
+ for (const el of Array.from(root.querySelectorAll('*'))) {
348
+ if (seen.has(el)) continue;
349
+ seen.add(el);
350
+ if (el.shadowRoot) roots.push(el.shadowRoot);
351
+ }
352
+ }
353
+
354
+ const isVisible = (el: Element): boolean => {
355
+ if (!(el instanceof HTMLElement)) return false;
356
+ const style = window.getComputedStyle(el);
357
+ const rect = el.getBoundingClientRect();
358
+ return (
359
+ style.visibility !== 'hidden' &&
360
+ style.display !== 'none' &&
361
+ rect.width > 0 &&
362
+ rect.height > 0
363
+ );
364
+ };
365
+
366
+ const isDisabled = (el: Element): boolean => {
367
+ if (!(el instanceof HTMLElement)) return true;
368
+ return (
369
+ (el as HTMLButtonElement).disabled === true ||
370
+ el.getAttribute('disabled') !== null ||
371
+ el.getAttribute('aria-disabled') === 'true' ||
372
+ el.getAttribute('with-disabled') === 'true' ||
373
+ el.closest(
374
+ '[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
375
+ ) !== null
376
+ );
377
+ };
378
+
379
+ const readText = (el: Element): string =>
380
+ [
381
+ (el as HTMLElement).innerText,
382
+ el.textContent,
383
+ el.getAttribute('aria-label'),
384
+ el.getAttribute('value'),
385
+ el.getAttribute('part'),
386
+ el.getAttribute('slot'),
387
+ el.getAttribute('data-px'),
388
+ el.getAttribute('with-data-px'),
389
+ el.getAttribute('with-px-label'),
390
+ ]
391
+ .filter(Boolean)
392
+ .join(' ')
393
+ .replace(/\s+/g, ' ')
394
+ .trim()
395
+ .toLowerCase();
396
+
397
+ const resolveClickable = (el: Element): HTMLElement | null => {
398
+ const direct = (el as HTMLElement).closest?.(
399
+ 'button, a, [role="button"], tdl-button, tii-grn-button, [slot="accept-button"]',
400
+ ) as HTMLElement | null;
401
+ const root = el.getRootNode();
402
+ const host =
403
+ root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
404
+ const clickable = direct || host || (el as HTMLElement);
405
+ const innerButton = clickable.shadowRoot?.querySelector(
406
+ 'button:not([disabled]), input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])',
407
+ );
408
+ return innerButton instanceof HTMLElement
409
+ ? innerButton
410
+ : clickable;
411
+ };
412
+
413
+ const candidates: Element[] = [];
414
+ for (const root of roots) {
415
+ candidates.push(...Array.from(root.querySelectorAll(selector)));
416
+ candidates.push(...Array.from(root.querySelectorAll('*')));
417
+ }
418
+
419
+ const normalizedLabels = labels.map((l) => l.toLowerCase());
420
+ const target = candidates
421
+ .filter(isVisible)
422
+ .map((el) => {
423
+ const rect = el.getBoundingClientRect();
424
+ const text = readText(el);
425
+ const labelIndex = normalizedLabels.findIndex((l) =>
426
+ text.includes(l),
427
+ );
428
+ const clickable = resolveClickable(el);
429
+ return {
430
+ element: el,
431
+ clickable,
432
+ labelIndex,
433
+ area: rect.width * rect.height,
434
+ disabled: clickable ? isDisabled(clickable) : true,
435
+ };
436
+ })
437
+ .filter((c) => c.labelIndex >= 0 && !c.disabled && c.clickable)
438
+ .sort(
439
+ (a, b) => a.labelIndex - b.labelIndex || a.area - b.area,
440
+ )[0]?.clickable;
441
+
442
+ if (!target) return false;
443
+ target.scrollIntoView({ block: 'center', inline: 'center' });
444
+ target.click();
445
+ return true;
446
+ },
447
+ { labels: QUOTA_CONFIRM_LABELS, selector: QUOTA_ACTION_SELECTOR },
448
+ )
449
+ .catch(() => false);
450
+ }
451
+
452
+ async function clickQuotaWarningContinueByCoordinates(
453
+ page: Page,
454
+ scope: Scope,
455
+ ): Promise<boolean> {
456
+ // Try filled action slot button first
457
+ const filledAction = scope
458
+ .locator(SELECTORS.quota.warningConfirmButton)
459
+ .last();
460
+ if (
461
+ await filledAction.isVisible({ timeout: 1000 }).catch(() => false)
462
+ ) {
463
+ await filledAction.click({ force: true });
464
+ return true;
465
+ }
466
+
467
+ // Try coordinate-based click on "Continue to File Upload"
468
+ const label = 'Continue to File Upload';
469
+ const textTarget = scope.getByText(label, { exact: false }).last();
470
+ const box = await textTarget
471
+ .boundingBox({ timeout: 1000 })
472
+ .catch(() => null);
473
+ if (box) {
474
+ await page.mouse
475
+ .click(box.x + box.width / 2, box.y + box.height / 2)
476
+ .catch(() => {});
477
+ return true;
478
+ }
479
+
480
+ // Deep shadow DOM coordinate search
481
+ const point = await scope
482
+ .evaluate(() => {
483
+ const roots: (Document | ShadowRoot)[] = [document];
484
+ const seen = new Set<Element>();
485
+
486
+ for (let i = 0; i < roots.length; i++) {
487
+ const root = roots[i];
488
+ for (const el of Array.from(root.querySelectorAll('*'))) {
489
+ if (seen.has(el)) continue;
490
+ seen.add(el);
491
+ if (el.shadowRoot) roots.push(el.shadowRoot);
492
+ }
493
+ }
494
+
495
+ const isVisible = (el: Element): boolean => {
496
+ if (!(el instanceof HTMLElement)) return false;
497
+ const style = window.getComputedStyle(el);
498
+ const rect = el.getBoundingClientRect();
499
+ return (
500
+ style.visibility !== 'hidden' &&
501
+ style.display !== 'none' &&
502
+ rect.width > 0 &&
503
+ rect.height > 0
504
+ );
505
+ };
506
+
507
+ const target = roots
508
+ .flatMap((r) => Array.from(r.querySelectorAll('*')))
509
+ .filter(isVisible)
510
+ .map((el) => {
511
+ const rect = el.getBoundingClientRect();
512
+ const text = [
513
+ (el as HTMLElement).innerText,
514
+ el.textContent,
515
+ ]
516
+ .filter(Boolean)
517
+ .join(' ')
518
+ .replace(/\s+/g, ' ')
519
+ .trim()
520
+ .toLowerCase();
521
+ return { rect, text, area: rect.width * rect.height };
522
+ })
523
+ .filter((c) => c.text.includes('continue to file upload'))
524
+ .sort((a, b) => a.area - b.area)[0];
525
+
526
+ if (!target) return null;
527
+ return {
528
+ x: target.rect.x + target.rect.width / 2,
529
+ y: target.rect.y + target.rect.height / 2,
530
+ };
531
+ })
532
+ .catch(() => null);
533
+
534
+ if (point) {
535
+ await page.mouse.click(point.x, point.y).catch(() => {});
536
+ return true;
537
+ }
538
+
539
+ // Final fallback: click approximate center of common dialog area
540
+ await page.mouse.click(890, 670).catch(() => {});
541
+ return true;
542
+ }
src/engine/steps/resubmit.ts ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ SELECTORS,
5
+ deepClickByText,
6
+ findAssignmentScope,
7
+ findScopeByDeepText,
8
+ readScopeVisibleDeepText,
9
+ } from '../selectors';
10
+ import { detectQuotaLimit, confirmQuotaWarning } from './quota-detect';
11
+ import { uploadFile, SubmissionQuotaLimitError, hasSubmissionCard } from './upload';
12
+
13
+ type Scope = Page | Frame;
14
+
15
+ async function clickVisibleResubmit(scope: Scope): Promise<boolean> {
16
+ // New UI: tii-grn-button inside tii-workflow-student-summary-panel-new
17
+ // Old UI: tdl-button or button with text "Resubmit"
18
+ const locator = scope
19
+ .locator(
20
+ 'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
21
+ )
22
+ .first();
23
+
24
+ if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
25
+ await locator.click({ force: true });
26
+ return true;
27
+ }
28
+
29
+ const clickedByDom = await scope
30
+ .evaluate(() => {
31
+ const roots: (Document | ShadowRoot)[] = [document];
32
+ const seen = new Set<Element>();
33
+
34
+ for (let i = 0; i < roots.length; i++) {
35
+ const root = roots[i];
36
+ for (const el of Array.from(root.querySelectorAll('*'))) {
37
+ if (seen.has(el)) continue;
38
+ seen.add(el);
39
+ if (el.shadowRoot) roots.push(el.shadowRoot);
40
+ }
41
+ }
42
+
43
+ const isVisible = (el: Element): boolean => {
44
+ if (!(el instanceof HTMLElement)) return false;
45
+ const style = window.getComputedStyle(el);
46
+ const rect = el.getBoundingClientRect();
47
+ return (
48
+ style.visibility !== 'hidden' &&
49
+ style.display !== 'none' &&
50
+ rect.width > 0 &&
51
+ rect.height > 0
52
+ );
53
+ };
54
+
55
+ const readText = (el: Element): string =>
56
+ [
57
+ (el as HTMLElement).innerText,
58
+ el.textContent,
59
+ el.getAttribute('aria-label'),
60
+ el.getAttribute('value'),
61
+ el.getAttribute('part'),
62
+ el.getAttribute('data-px'),
63
+ el.getAttribute('with-data-px'),
64
+ ]
65
+ .filter(Boolean)
66
+ .join(' ')
67
+ .replace(/\s+/g, ' ')
68
+ .trim()
69
+ .toLowerCase();
70
+
71
+ const candidates: Element[] = [];
72
+ for (const root of roots) {
73
+ candidates.push(
74
+ ...Array.from(
75
+ root.querySelectorAll(
76
+ // Include tii-grn-button for new UI
77
+ 'button, a, input, tdl-button, tii-grn-button, [role="button"]',
78
+ ),
79
+ ),
80
+ );
81
+ }
82
+
83
+ const target = candidates.find((el) => isVisible(el) && readText(el).includes('resubmit'));
84
+ if (!target) return false;
85
+
86
+ const shadowButton = target.shadowRoot?.querySelector(
87
+ 'button:not([disabled]), [role="button"]:not([disabled])',
88
+ );
89
+ const clickTarget =
90
+ shadowButton instanceof HTMLElement ? shadowButton : (target as HTMLElement);
91
+ clickTarget.scrollIntoView({ block: 'center', inline: 'center' });
92
+ clickTarget.click();
93
+ return true;
94
+ })
95
+ .catch(() => false);
96
+
97
+ if (clickedByDom) return true;
98
+ return deepClickByText(scope, ['resubmit']);
99
+ }
100
+
101
+ /**
102
+ * Handle the new Turnitin resubmission confirmation modal.
103
+ * The dialog#tii-resubmission-last-submission-modal is rendered INSIDE a
104
+ * web component's shadow DOM, so standard document.querySelector() and even
105
+ * Playwright's page.locator() may not find it.
106
+ *
107
+ * This function uses a full recursive shadow-DOM traversal to locate the
108
+ * dialog and click its "Continue to File Upload" (or any accept-like) button.
109
+ */
110
+ async function confirmResubmissionModal(page: Page): Promise<boolean> {
111
+ // Helper: find dialog anywhere in the full shadow DOM tree
112
+ const findDialog = () => page.evaluate(() => {
113
+ const roots: (Document | ShadowRoot)[] = [document];
114
+ const seen = new Set<Element>();
115
+ for (let i = 0; i < roots.length; i++) {
116
+ const root = roots[i];
117
+ for (const el of Array.from(root.querySelectorAll('*'))) {
118
+ if (seen.has(el)) continue;
119
+ seen.add(el);
120
+ if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
121
+ }
122
+ }
123
+ for (const root of roots) {
124
+ if (root.querySelector('dialog#tii-resubmission-last-submission-modal,dialog[open]')) return true;
125
+ }
126
+ return false;
127
+ }).catch(() => false);
128
+
129
+ // Wait up to 6s for the modal to appear (shadow DOM traversal)
130
+ let found = false;
131
+ const deadline = Date.now() + 6000;
132
+ while (!found && Date.now() < deadline) {
133
+ found = await findDialog();
134
+ if (!found) await page.waitForTimeout(400);
135
+ }
136
+
137
+ if (!found) {
138
+ // Also try the standard Playwright locator as a fallback (works when dialog is in light DOM)
139
+ found = await page
140
+ .locator('dialog#tii-resubmission-last-submission-modal, dialog[open]')
141
+ .first()
142
+ .isVisible({ timeout: 2000 })
143
+ .catch(() => false);
144
+ }
145
+
146
+ if (!found) {
147
+ logger.info('No resubmission confirmation modal detected');
148
+ return false;
149
+ }
150
+
151
+ logger.info('Resubmission confirmation modal detected; clicking Continue to File Upload...');
152
+
153
+ // Click the accept button via full shadow-DOM traversal
154
+ const clicked = await page.evaluate(() => {
155
+ // Build complete shadow-DOM root list
156
+ const roots: (Document | ShadowRoot)[] = [document];
157
+ const seen = new Set<Element>();
158
+ for (let i = 0; i < roots.length; i++) {
159
+ const root = roots[i];
160
+ for (const el of Array.from(root.querySelectorAll('*'))) {
161
+ if (seen.has(el)) continue;
162
+ seen.add(el);
163
+ if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
164
+ }
165
+ }
166
+
167
+ // Find the dialog first
168
+ let dialog: Element | null = null;
169
+ for (const root of roots) {
170
+ dialog = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
171
+ root.querySelector('dialog[open]');
172
+ if (dialog) break;
173
+ }
174
+ if (!dialog) return false;
175
+
176
+ // Traverse dialog's own shadow DOM
177
+ const dialogRoots: (Element | ShadowRoot)[] = [dialog];
178
+ const dSeen = new Set<Element>();
179
+ for (let i = 0; i < dialogRoots.length; i++) {
180
+ const root = dialogRoots[i];
181
+ for (const el of Array.from(root.querySelectorAll('*'))) {
182
+ if (dSeen.has(el as Element)) continue;
183
+ dSeen.add(el as Element);
184
+ if ((el as any).shadowRoot) dialogRoots.push((el as any).shadowRoot);
185
+ }
186
+ }
187
+
188
+ // Priority: "Continue to File Upload" > any accept/submit/confirm button
189
+ const priority = (text: string): number => {
190
+ const t = text.toLowerCase();
191
+ if (t.includes('continue to file upload')) return 100;
192
+ if (t.includes('file upload')) return 90;
193
+ if (t.includes('continue')) return 80;
194
+ if (t.includes('accept')) return 70;
195
+ if (t.includes('submit')) return 60;
196
+ if (t.includes('confirm')) return 50;
197
+ return 0;
198
+ };
199
+
200
+ const candidates: Array<{ el: Element; score: number }> = [];
201
+ for (const root of dialogRoots) {
202
+ for (const el of Array.from(
203
+ root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], [slot="accept-button"]'),
204
+ )) {
205
+ const text = [
206
+ (el as HTMLElement).innerText,
207
+ el.textContent,
208
+ el.getAttribute('aria-label'),
209
+ el.getAttribute('part'),
210
+ el.getAttribute('slot'),
211
+ el.getAttribute('data-px'),
212
+ el.getAttribute('with-data-px'),
213
+ ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
214
+
215
+ const score = priority(text);
216
+ if (score > 0) candidates.push({ el, score });
217
+ }
218
+ }
219
+
220
+ candidates.sort((a, b) => b.score - a.score);
221
+ const best = candidates[0];
222
+ if (!best) {
223
+ // Last resort: close the dialog directly
224
+ if (dialog instanceof HTMLDialogElement) {
225
+ dialog.close();
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ // Click: prefer inner shadow <button>, else click host element
232
+ const inner = (best.el as any).shadowRoot?.querySelector('button:not([disabled])');
233
+ const target = inner instanceof HTMLElement ? inner : (best.el as HTMLElement);
234
+ target.scrollIntoView({ block: 'center', inline: 'center' });
235
+ target.click();
236
+ return true;
237
+ }).catch(() => false);
238
+
239
+ if (clicked) {
240
+ await page.waitForTimeout(2000);
241
+
242
+ // Verify dialog actually closed (wait up to 3s)
243
+ const stillOpen = await findDialog();
244
+ if (stillOpen) {
245
+ logger.warn('Dialog still open after click; trying dialog.close() fallback');
246
+ await page.evaluate(() => {
247
+ const roots: (Document | ShadowRoot)[] = [document];
248
+ const seen = new Set<Element>();
249
+ for (let i = 0; i < roots.length; i++) {
250
+ const root = roots[i];
251
+ for (const el of Array.from(root.querySelectorAll('*'))) {
252
+ if (seen.has(el)) continue;
253
+ seen.add(el);
254
+ if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
255
+ }
256
+ }
257
+ for (const root of roots) {
258
+ const d = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
259
+ root.querySelector('dialog[open]');
260
+ if (d instanceof HTMLDialogElement) { d.close(); return; }
261
+ }
262
+ }).catch(() => {});
263
+ await page.waitForTimeout(1500);
264
+ }
265
+ }
266
+
267
+ return clicked;
268
+ }
269
+
270
+ async function hasFirstSubmissionUploadForm(page: Page): Promise<boolean> {
271
+ const scope =
272
+ (await findAssignmentScope(
273
+ page,
274
+ `${SELECTORS.upload.browseButton}, ${SELECTORS.upload.fileInput}, ${SELECTORS.upload.uploadStepContainer}`,
275
+ 2000,
276
+ )) || (await findScopeByDeepText(
277
+ page,
278
+ ['browse files', 'drag and drop file', 'drag and drop your file', 'your device'],
279
+ 2000,
280
+ ));
281
+
282
+ if (!scope) return false;
283
+ const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase();
284
+ return (
285
+ (await scope.locator(SELECTORS.upload.fileInput).first().isVisible({ timeout: 500 }).catch(() => false)) ||
286
+ (await scope.locator(SELECTORS.upload.browseButton).first().isVisible({ timeout: 500 }).catch(() => false)) ||
287
+ (await scope.locator(SELECTORS.upload.uploadStepContainer).first().isVisible({ timeout: 500 }).catch(() => false)) ||
288
+ visibleText.includes('browse files') ||
289
+ visibleText.includes('drag and drop file') ||
290
+ visibleText.includes('drag and drop your file') ||
291
+ visibleText.includes('your device')
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Resubmit a file to an assignment that already has a prior submission.
297
+ *
298
+ * Steps:
299
+ * 1. Assert quota is available
300
+ * 2. Click Resubmit button
301
+ * 3. Handle quota warnings (confirm and continue)
302
+ * 4. Use same upload popup flow (Browse Files -> Upload and Preview -> Submit)
303
+ * 5. If Resubmit button not found, fallback to initial upload
304
+ */
305
+ export async function resubmitFile(
306
+ page: Page,
307
+ filePath: string,
308
+ ): Promise<void> {
309
+ logger.info('Resubmit requested; opening resubmission upload modal');
310
+
311
+ // Check quota before attempting resubmit
312
+ const quotaLimit = await detectQuotaLimit(page);
313
+ if (quotaLimit) {
314
+ throw new SubmissionQuotaLimitError(quotaLimit.message);
315
+ }
316
+
317
+ // Find the Resubmit button
318
+ const scope =
319
+ (await findAssignmentScope(
320
+ page,
321
+ 'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
322
+ 5000,
323
+ )) ||
324
+ (await findScopeByDeepText(page, ['resubmit'], 8000));
325
+ if (!scope) {
326
+ if (await hasFirstSubmissionUploadForm(page)) {
327
+ logger.warn(
328
+ 'Resubmit button was not found and first-submission upload form is visible; using initial upload flow',
329
+ );
330
+ await uploadFile(page, filePath);
331
+ return;
332
+ }
333
+ throw new Error('Resubmit was requested, but no visible Resubmit button or first-upload form was found');
334
+ }
335
+
336
+ let clicked = await clickVisibleResubmit(scope);
337
+
338
+ if (!clicked) {
339
+ const submissionCardVisible = await hasSubmissionCard(scope);
340
+ if (!submissionCardVisible && await hasFirstSubmissionUploadForm(page)) {
341
+ logger.warn(
342
+ 'Resubmit button click failed, but no submission card is visible; using initial upload flow',
343
+ );
344
+ await uploadFile(page, filePath);
345
+ return;
346
+ }
347
+ throw new Error('Resubmit button was found but could not be clicked');
348
+ }
349
+
350
+ await page.waitForTimeout(2000);
351
+
352
+ // Handle NEW UI resubmission confirmation modal
353
+ // (dialog#tii-resubmission-last-submission-modal with accept-button slot)
354
+ const modalConfirmed = await confirmResubmissionModal(page);
355
+ if (modalConfirmed) {
356
+ logger.info('Resubmission confirmation modal accepted');
357
+ await page.waitForTimeout(2000);
358
+ }
359
+
360
+ // Check quota again after clicking resubmit
361
+ const quotaLimitAfter = await detectQuotaLimit(page);
362
+ if (quotaLimitAfter) {
363
+ throw new SubmissionQuotaLimitError(quotaLimitAfter.message);
364
+ }
365
+
366
+ // Handle quota warnings
367
+ await confirmQuotaWarning(page);
368
+
369
+ // Now proceed with the standard upload flow (the resubmit popup is the same)
370
+ await uploadFile(page, filePath, { skipExistingCheck: true });
371
+ }
src/engine/steps/similarity.ts ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame, Locator } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ readScopeDeepText,
5
+ resolveAssignmentScope,
6
+ SELECTORS,
7
+ } from '../selectors';
8
+ import { acceptEulaEverywhere } from './login';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface SimilarityResult {
15
+ similarityPercent: number | null;
16
+ viewerUrl: string | null;
17
+ scope?: Page | Frame;
18
+ locator?: Locator;
19
+ }
20
+
21
+ export interface WaitForSimilarityOptions {
22
+ timeoutMs: number;
23
+ pollMs: number;
24
+ refreshAfterMs: number;
25
+ inputFileName?: string;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Public API
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function getSubmissionButtonLocator(scope: Page | Frame, inputTitle: string): Locator {
33
+ const viewButtons = scope.locator(SELECTORS.similarity.viewSubmissionButton);
34
+ if (inputTitle) {
35
+ return viewButtons.filter({ hasText: inputTitle }).first();
36
+ }
37
+ return viewButtons.first();
38
+ }
39
+
40
+ /**
41
+ * Poll for the similarity score to appear on the submission card.
42
+ *
43
+ * After `refreshAfterMs` with no result, refreshes the assignment launch URL
44
+ * once. Returns the similarity percent (if readable) and viewer URL.
45
+ */
46
+ export async function waitForSimilarity(
47
+ page: Page,
48
+ assignmentLaunchUrl: string,
49
+ options: WaitForSimilarityOptions,
50
+ ): Promise<SimilarityResult> {
51
+ const { timeoutMs, pollMs, refreshAfterMs } = options;
52
+ const inputTitle = options.inputFileName
53
+ ? options.inputFileName.replace(/\.[^.]+$/, '').trim()
54
+ : '';
55
+ const started = Date.now();
56
+ let lastText = '';
57
+ // BUG-11 FIX: Allow multiple refreshes (every refreshAfterMs from first refresh)
58
+ let refreshCount = 0;
59
+ const MAX_REFRESHES = 2;
60
+ let lastRefreshAt = 0;
61
+
62
+ while (Date.now() - started < timeoutMs) {
63
+ // ---- Deep text scan across all scopes ----
64
+ const scopes: (Page | Frame)[] = [
65
+ page,
66
+ ...page.frames().filter((f) => !f.url().includes('cookie-shim')),
67
+ ];
68
+
69
+ for (const candidateScope of scopes) {
70
+ const deepText = await readScopeDeepText(candidateScope);
71
+ const percentMatch = deepText.match(
72
+ /\b(?:Similarity:\s*)?(\d{1,3})%\b/i,
73
+ );
74
+ // BUG-4 FIX: Validate bounds — regex now matches 0-999 so we clamp to 0-100
75
+ const percentRaw = percentMatch ? parseInt(percentMatch[1], 10) : NaN;
76
+ const percent = Number.isFinite(percentRaw) && percentRaw >= 0 && percentRaw <= 100
77
+ ? percentRaw : NaN;
78
+ const percentOk = !isNaN(percent);
79
+ const titleVisible =
80
+ Boolean(inputTitle) &&
81
+ deepText.toLowerCase().includes(inputTitle.toLowerCase());
82
+
83
+ if (
84
+ percentOk &&
85
+ (titleVisible || /Submitted|Similarity|Your work/i.test(deepText))
86
+ ) {
87
+ const text = percentMatch![0].includes('Similarity')
88
+ ? percentMatch![0]
89
+ : `Similarity: ${percent}%`;
90
+ logger.info('Similarity detected from deep text', {
91
+ text,
92
+ percent,
93
+ });
94
+ return {
95
+ similarityPercent: percent,
96
+ viewerUrl: null,
97
+ scope: candidateScope,
98
+ locator: getSubmissionButtonLocator(candidateScope, inputTitle),
99
+ };
100
+ }
101
+
102
+ // After refresh, if submission row is visible even without percent, proceed
103
+ if (
104
+ refreshCount > 0 &&
105
+ titleVisible &&
106
+ /Your work/i.test(deepText)
107
+ ) {
108
+ logger.info(
109
+ 'Submission row visible after refresh; proceeding without similarity value',
110
+ );
111
+ return {
112
+ similarityPercent: null,
113
+ viewerUrl: null,
114
+ scope: candidateScope,
115
+ locator: getSubmissionButtonLocator(candidateScope, inputTitle),
116
+ };
117
+ }
118
+ }
119
+
120
+ // ---- Locator-based scan ----
121
+ const scope = await resolveAssignmentScope(page);
122
+ const candidates = [
123
+ {
124
+ kind: 'similarity',
125
+ locator: scope
126
+ .locator(
127
+ 'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
128
+ )
129
+ .first(),
130
+ },
131
+ {
132
+ kind: 'similarity',
133
+ locator: scope.locator(SELECTORS.similarity.similaritySpan).first(),
134
+ },
135
+ {
136
+ kind: 'similarity',
137
+ locator: scope.locator(SELECTORS.similarity.similarityDisplay).first(),
138
+ },
139
+ {
140
+ kind: 'title',
141
+ locator: scope.locator(SELECTORS.similarity.viewSubmissionButton).first(),
142
+ },
143
+ ];
144
+
145
+ for (const candidateEntry of candidates) {
146
+ const candidate = candidateEntry.locator;
147
+ if (
148
+ await candidate.isVisible({ timeout: 1500 }).catch(() => false)
149
+ ) {
150
+ const text = (
151
+ await candidate.innerText().catch(() => '')
152
+ ).trim();
153
+ const aria = (
154
+ (await candidate
155
+ .getAttribute('aria-label')
156
+ .catch(() => '')) || ''
157
+ ).trim();
158
+ const visibleText = text || aria;
159
+
160
+ if (visibleText && visibleText !== lastText) {
161
+ lastText = visibleText;
162
+ logger.info('Submission state', { visibleText });
163
+ }
164
+
165
+ if (
166
+ candidateEntry.kind === 'title' &&
167
+ visibleText &&
168
+ refreshCount > 0
169
+ ) {
170
+ return {
171
+ similarityPercent: null,
172
+ viewerUrl: null,
173
+ scope,
174
+ locator: candidate,
175
+ };
176
+ }
177
+
178
+ const simMatch = visibleText.match(
179
+ /Similarity:\s*(\d+)%/i,
180
+ );
181
+ if (simMatch) {
182
+ const percent = parseInt(simMatch[1], 10);
183
+ logger.info('Similarity detected from locator', {
184
+ visibleText,
185
+ percent,
186
+ });
187
+ return {
188
+ similarityPercent: percent,
189
+ viewerUrl: null,
190
+ scope,
191
+ locator: getSubmissionButtonLocator(scope, inputTitle),
192
+ };
193
+ }
194
+ }
195
+ }
196
+
197
+ // ---- Log body text for diagnostics ----
198
+ const bodyText = await scope
199
+ .locator('body')
200
+ .innerText({ timeout: 3000 })
201
+ .catch(() => '');
202
+ const shortText = bodyText.replace(/\s+/g, ' ').trim().slice(0, 240);
203
+ if (shortText && shortText !== lastText) {
204
+ lastText = shortText;
205
+ logger.debug('Waiting for submission/similarity', {
206
+ bodySnippet: shortText,
207
+ });
208
+ }
209
+
210
+ // ---- Refresh assignment launch URL periodically ----
211
+ // BUG-11 FIX: Allow up to MAX_REFRESHES refreshes, each triggered after
212
+ // refreshAfterMs has passed since the previous refresh (or since start).
213
+ const elapsedSinceLastRefresh = lastRefreshAt === 0
214
+ ? Date.now() - started
215
+ : Date.now() - lastRefreshAt;
216
+ const currentUrl = page.url();
217
+ const urlSeemsFine =
218
+ currentUrl.includes('turnitin.com') &&
219
+ (currentUrl.includes('/assignment/') || currentUrl.includes('/class/'));
220
+ const shouldRefresh =
221
+ refreshCount < MAX_REFRESHES &&
222
+ elapsedSinceLastRefresh >= refreshAfterMs &&
223
+ (!urlSeemsFine || currentUrl.includes('/assignment/type/tool/launch'));
224
+
225
+ if (shouldRefresh) {
226
+ refreshCount++;
227
+ lastRefreshAt = Date.now();
228
+ logger.info(
229
+ `Similarity not visible; refreshing assignment launch URL (refresh ${refreshCount}/${MAX_REFRESHES})`,
230
+ { currentUrl },
231
+ );
232
+ await page
233
+ .reload({ waitUntil: 'domcontentloaded', timeout: 60000 })
234
+ .catch(() => {});
235
+ await page.waitForTimeout(5000);
236
+
237
+ // ── Handle 502/503/504 error pages after refresh ──
238
+ for (let refreshRetry = 0; refreshRetry < 2; refreshRetry++) {
239
+ const bodyAfterRefresh = await page
240
+ .locator('body')
241
+ .innerText({ timeout: 3000 })
242
+ .catch(() => '');
243
+ if (/502 Bad Gateway|503 Service|504 Gateway/i.test(bodyAfterRefresh)) {
244
+ logger.warn(
245
+ `Server error detected after refresh ${refreshCount} (attempt ${refreshRetry + 1}/2); retrying reload`,
246
+ { bodySnippet: bodyAfterRefresh.slice(0, 200) },
247
+ );
248
+ await page.waitForTimeout(5000);
249
+ await page
250
+ .reload({ waitUntil: 'domcontentloaded', timeout: 60000 })
251
+ .catch(() => {});
252
+ await page.waitForTimeout(5000);
253
+ } else {
254
+ break;
255
+ }
256
+ }
257
+
258
+ continue;
259
+ }
260
+
261
+ await page.waitForTimeout(pollMs);
262
+ }
263
+
264
+ throw new Error(
265
+ `Submission card/similarity did not become ready within ${timeoutMs}ms`,
266
+ );
267
+ }
src/engine/steps/submission-details.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import { SELECTORS, deepClickByText } from '../selectors';
4
+
5
+ type Scope = Page | Frame;
6
+
7
+ export interface SubmissionDetails {
8
+ studentId?: string;
9
+ className?: string;
10
+ classId?: string;
11
+ submissionId?: string;
12
+ submissionDate?: string;
13
+ submissionCount?: number;
14
+ fileName?: string;
15
+ fileExtension?: string;
16
+ fileSize?: number;
17
+ charCount?: number;
18
+ wordCount?: number;
19
+ pageCount?: number;
20
+ }
21
+
22
+ function normalizeKey(label: string): keyof SubmissionDetails | null {
23
+ const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
24
+ if (normalized === 'student id') return 'studentId';
25
+ if (normalized === 'class name') return 'className';
26
+ if (normalized === 'class id') return 'classId';
27
+ if (normalized === 'submission id') return 'submissionId';
28
+ if (normalized === 'submission date') return 'submissionDate';
29
+ if (normalized === 'submission count') return 'submissionCount';
30
+ if (normalized === 'file name') return 'fileName';
31
+ if (normalized === 'file extension') return 'fileExtension';
32
+ if (normalized === 'file size') return 'fileSize';
33
+ if (normalized === 'char count') return 'charCount';
34
+ if (normalized === 'character count') return 'charCount';
35
+ if (normalized === 'word count') return 'wordCount';
36
+ if (normalized === 'page count') return 'pageCount';
37
+ return null;
38
+ }
39
+
40
+ function parseNumber(value: string): number | undefined {
41
+ const numeric = Number(String(value || '').replace(/[^\d]/g, ''));
42
+ return Number.isFinite(numeric) ? numeric : undefined;
43
+ }
44
+
45
+ /**
46
+ * Open the Turnitin viewer details panel and read file metadata. The worker
47
+ * emits these details to job events so the tracking page can show file facts
48
+ * before/while the final report is downloaded.
49
+ */
50
+ export async function readSubmissionDetails(
51
+ page: Scope,
52
+ ): Promise<SubmissionDetails | null> {
53
+ const detailsButton = page
54
+ .locator(
55
+ [
56
+ 'tii-sws-submission-details-btn tdl-labeled-button',
57
+ 'tii-sws-submission-details-btn',
58
+ 'tii-sws-header [slot="submission-details-btn"]',
59
+ 'tdl-labeled-button[withdatapx="SubmissionDetailsMenuClicked"]',
60
+ '[withdatapx="SubmissionDetailsMenuClicked"]',
61
+ 'button:has-text("Details")',
62
+ 'tdl-labeled-button:has-text("Details")',
63
+ ].join(', '),
64
+ )
65
+ .first();
66
+
67
+ if (!(await detailsButton.isVisible({ timeout: 10000 }).catch(() => false))) {
68
+ const clickedByText = await deepClickByText(page, ['details']).catch(
69
+ () => false,
70
+ );
71
+ if (!clickedByText) {
72
+ logger.warn('Submission details button was not visible');
73
+ return null;
74
+ }
75
+ } else {
76
+ await detailsButton.click({ force: true });
77
+ }
78
+
79
+ await page.waitForTimeout(1200).catch(() => {});
80
+
81
+ const fileTab = page
82
+ .locator(
83
+ [
84
+ 'tii-sws-tab-button#sub-details-tab-file',
85
+ '#sub-details-tab-file',
86
+ '.submission-details-tab:has-text("File")',
87
+ '[role="tab"]:has-text("File")',
88
+ ].join(', '),
89
+ )
90
+ .first();
91
+ if (await fileTab.isVisible({ timeout: 5000 }).catch(() => false)) {
92
+ await fileTab.click({ force: true }).catch(() => {});
93
+ await page.waitForTimeout(700).catch(() => {});
94
+ } else {
95
+ await deepClickByText(page, ['file']).catch(() => false);
96
+ await page.waitForTimeout(700).catch(() => {});
97
+ }
98
+
99
+ const details = await page
100
+ .locator('#tii-sws-submission-details-list')
101
+ .first()
102
+ .evaluate((list) => {
103
+ return Array.from(list.querySelectorAll('.submission-details-item')).map(
104
+ (item) => {
105
+ const term = item
106
+ .querySelector('[role="term"]')
107
+ ?.textContent?.trim() || '';
108
+ const value = item
109
+ .querySelector('[role="definition"], .submission-details-value')
110
+ ?.textContent?.trim() || '';
111
+ return { term, value };
112
+ },
113
+ );
114
+ })
115
+ .catch(() => []);
116
+
117
+ const result: SubmissionDetails = {};
118
+ for (const row of details) {
119
+ const key = normalizeKey(row.term);
120
+ if (!key) continue;
121
+ if (
122
+ key === 'fileName' ||
123
+ key === 'fileExtension' ||
124
+ key === 'studentId' ||
125
+ key === 'className' ||
126
+ key === 'classId' ||
127
+ key === 'submissionId' ||
128
+ key === 'submissionDate'
129
+ ) {
130
+ result[key] = row.value;
131
+ } else {
132
+ const parsed = parseNumber(row.value);
133
+ if (parsed !== undefined) result[key] = parsed;
134
+ }
135
+ }
136
+
137
+ await page
138
+ .locator(SELECTORS.filters.similarityTab)
139
+ .first()
140
+ .click({ force: true, timeout: 3000 })
141
+ .catch(() => {});
142
+ await page.waitForTimeout(800).catch(() => {});
143
+
144
+ if (Object.keys(result).length === 0) return null;
145
+ logger.info('Read submission details from viewer', { ...result });
146
+ return result;
147
+ }
src/engine/steps/submission-state.ts ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import {
3
+ findAssignmentScope,
4
+ readScopeVisibleDeepText,
5
+ scopeHasVisibleDeepText,
6
+ } from '../selectors';
7
+ import { SELECTORS } from '../selectors';
8
+
9
+ type Scope = Page | Frame;
10
+
11
+ export interface SubmissionState {
12
+ scope: Scope | null;
13
+ hasExistingSubmission: boolean;
14
+ hasResubmitAction: boolean;
15
+ hasUploadForm: boolean;
16
+ }
17
+
18
+ async function hasVisible(scope: Scope, selector: string, timeoutMs = 1000): Promise<boolean> {
19
+ return scope.locator(selector).first().isVisible({ timeout: timeoutMs }).catch(() => false);
20
+ }
21
+
22
+ async function hasVisibleActionText(scope: Scope, textMatches: string[]): Promise<boolean> {
23
+ return scope
24
+ .evaluate((matches: string[]) => {
25
+ const normalizedMatches = matches.map((value) => value.toLowerCase());
26
+ const roots: (Document | ShadowRoot)[] = [document];
27
+ const seen = new Set<Element>();
28
+
29
+ for (let i = 0; i < roots.length; i++) {
30
+ const root = roots[i];
31
+ for (const el of Array.from(root.querySelectorAll('*'))) {
32
+ if (seen.has(el)) continue;
33
+ seen.add(el);
34
+ if (el.shadowRoot) roots.push(el.shadowRoot);
35
+ }
36
+ }
37
+
38
+ const isVisible = (el: Element): boolean => {
39
+ if (!(el instanceof HTMLElement)) return false;
40
+ const style = window.getComputedStyle(el);
41
+ const rect = el.getBoundingClientRect();
42
+ return (
43
+ style.visibility !== 'hidden' &&
44
+ style.display !== 'none' &&
45
+ rect.width > 0 &&
46
+ rect.height > 0
47
+ );
48
+ };
49
+
50
+ const candidates: Element[] = [];
51
+ for (const root of roots) {
52
+ candidates.push(
53
+ ...Array.from(
54
+ root.querySelectorAll(
55
+ 'button, a, input, tdl-button, tii-grn-button, tdl-labeled-button, [role="button"]',
56
+ ),
57
+ ),
58
+ );
59
+ }
60
+
61
+ return candidates.some((el) => {
62
+ if (!isVisible(el)) return false;
63
+ const text = [
64
+ (el as HTMLElement).innerText,
65
+ el.textContent,
66
+ el.getAttribute('aria-label'),
67
+ el.getAttribute('value'),
68
+ el.getAttribute('part'),
69
+ el.getAttribute('data-px'),
70
+ el.getAttribute('with-data-px'),
71
+ el.getAttribute('with-px-label'),
72
+ ]
73
+ .filter(Boolean)
74
+ .join(' ')
75
+ .replace(/\s+/g, ' ')
76
+ .trim()
77
+ .toLowerCase();
78
+ if (
79
+ text.includes('setting info') ||
80
+ text.includes('resubmissions are allowed') ||
81
+ text.includes('late submissions are allowed') ||
82
+ text.includes('collapse details') ||
83
+ text.includes('assignment details') ||
84
+ text.includes('rubric') ||
85
+ text.includes('template')
86
+ ) {
87
+ return false;
88
+ }
89
+ return normalizedMatches.some((match) => text.includes(match));
90
+ });
91
+ }, textMatches)
92
+ .catch(() => false);
93
+ }
94
+
95
+ async function hasInitialSubmitAction(scope: Scope): Promise<boolean> {
96
+ return scope
97
+ .evaluate(() => {
98
+ const roots: (Document | ShadowRoot)[] = [document];
99
+ const seen = new Set<Element>();
100
+
101
+ for (let i = 0; i < roots.length; i++) {
102
+ const root = roots[i];
103
+ for (const el of Array.from(root.querySelectorAll('*'))) {
104
+ if (seen.has(el)) continue;
105
+ seen.add(el);
106
+ if (el.shadowRoot) roots.push(el.shadowRoot);
107
+ }
108
+ }
109
+
110
+ const isVisible = (el: Element): boolean => {
111
+ if (!(el instanceof HTMLElement)) return false;
112
+ const style = window.getComputedStyle(el);
113
+ const rect = el.getBoundingClientRect();
114
+ return (
115
+ style.visibility !== 'hidden' &&
116
+ style.display !== 'none' &&
117
+ rect.width > 0 &&
118
+ rect.height > 0
119
+ );
120
+ };
121
+
122
+ const hasAncestorSignal = (el: Element): boolean => {
123
+ let current: Element | null = el;
124
+ const visited = new Set<Element>();
125
+ while (current && !visited.has(current)) {
126
+ visited.add(current);
127
+ const className = current.getAttribute('class') || '';
128
+ const slot = current.getAttribute('slot') || '';
129
+ const tag = current.tagName.toLowerCase();
130
+ if (
131
+ slot === 'submission-action' ||
132
+ className.includes('submission-action') ||
133
+ className.includes('submission-slot-container') ||
134
+ tag === 'tii-workflow-student-summary-panel-new' ||
135
+ tag === 'tii-workflow-lfw-student-show-assignment'
136
+ ) {
137
+ return true;
138
+ }
139
+
140
+ const parent: HTMLElement | null = current.parentElement;
141
+ if (parent) {
142
+ current = parent;
143
+ continue;
144
+ }
145
+ const root = current.getRootNode();
146
+ current = root instanceof ShadowRoot ? root.host : null;
147
+ }
148
+ return false;
149
+ };
150
+
151
+ for (const root of roots) {
152
+ for (const el of Array.from(
153
+ root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'),
154
+ )) {
155
+ if (!isVisible(el)) continue;
156
+ const text = [
157
+ (el as HTMLElement).innerText,
158
+ el.textContent,
159
+ el.getAttribute('aria-label'),
160
+ el.getAttribute('value'),
161
+ ]
162
+ .filter(Boolean)
163
+ .join(' ')
164
+ .replace(/\s+/g, ' ')
165
+ .trim();
166
+ const normalized = text.toLowerCase();
167
+ if (!/(^|\s)submit(\s|$)/i.test(text)) continue;
168
+ if (
169
+ normalized.includes('resubmit') ||
170
+ normalized.includes('submit file') ||
171
+ normalized.includes('upload and preview') ||
172
+ normalized.includes('collapse details') ||
173
+ normalized.includes('setting info')
174
+ ) {
175
+ continue;
176
+ }
177
+ if (hasAncestorSignal(el)) return true;
178
+ }
179
+ }
180
+
181
+ return false;
182
+ })
183
+ .catch(() => false);
184
+ }
185
+
186
+ /**
187
+ * Detect what Turnitin is currently showing after opening an assignment.
188
+ * This is intentionally based on the live page, because account history can
189
+ * differ from the requested job mode.
190
+ */
191
+ export async function detectSubmissionState(page: Page): Promise<SubmissionState> {
192
+ const scope =
193
+ (await findAssignmentScope(
194
+ page,
195
+ [
196
+ SELECTORS.resubmit.resubmitButton,
197
+ SELECTORS.similarity.viewSubmissionButton,
198
+ SELECTORS.upload.browseButton,
199
+ SELECTORS.upload.fileInput,
200
+ SELECTORS.resubmit.submissionActionPanel,
201
+ 'tii-workflow-lfw-student-show-assignment',
202
+ 'div[slot="submission-action"].submission-action',
203
+ 'tii-grn-button:has-text("Submit")',
204
+ ].join(', '),
205
+ 3000,
206
+ ));
207
+
208
+ if (!scope) {
209
+ return {
210
+ scope: null,
211
+ hasExistingSubmission: false,
212
+ hasResubmitAction: false,
213
+ hasUploadForm: false,
214
+ };
215
+ }
216
+
217
+ const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase();
218
+ const hasSubmitAction = await hasInitialSubmitAction(scope);
219
+ const hasResubmitAction =
220
+ (await hasVisible(scope, SELECTORS.resubmit.resubmitButton, 1500)) ||
221
+ (await hasVisible(
222
+ scope,
223
+ 'a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]',
224
+ 1500,
225
+ )) ||
226
+ (await hasVisibleActionText(scope, ['resubmit']));
227
+
228
+ const hasUploadForm =
229
+ (await hasVisible(scope, SELECTORS.upload.fileInput, 1500)) ||
230
+ (await hasVisible(scope, SELECTORS.upload.browseButton, 1500)) ||
231
+ hasSubmitAction ||
232
+ visibleText.includes('browse files') ||
233
+ visibleText.includes('drag and drop file') ||
234
+ visibleText.includes('your device');
235
+
236
+ const hasViewSubmissionAction =
237
+ (await hasVisible(
238
+ scope,
239
+ 'button.link-button[aria-label*="View submission"], a[aria-label*="View submission"], a.view-mark, .similarity-button',
240
+ 1500,
241
+ )) ||
242
+ (await hasVisibleActionText(scope, ['view submission']));
243
+
244
+ const hasExistingSubmission =
245
+ hasResubmitAction ||
246
+ hasViewSubmissionAction ||
247
+ (await hasVisible(scope, SELECTORS.similarity.similarityDisplay, 1500)) ||
248
+ (await hasVisible(scope, SELECTORS.similarity.similarityBadge, 1500)) ||
249
+ (visibleText.includes('similarity:') || visibleText.includes('view submission')) ||
250
+ ((await scopeHasVisibleDeepText(scope, ['submitted'])) && !hasUploadForm);
251
+
252
+ return {
253
+ scope,
254
+ // If the first-submission upload form is visible and there is no visible
255
+ // submission action, prefer upload. Turnitin web components often keep
256
+ // hidden "Resubmit" text in the DOM before any file exists.
257
+ hasExistingSubmission: hasUploadForm && !hasResubmitAction && !hasViewSubmissionAction
258
+ ? false
259
+ : hasExistingSubmission,
260
+ hasResubmitAction,
261
+ hasUploadForm,
262
+ };
263
+ }
src/engine/steps/upload.ts ADDED
@@ -0,0 +1,931 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ SELECTORS,
5
+ findAssignmentScope,
6
+ findScopeByDeepText,
7
+ scopeHasVisibleDeepText,
8
+ } from '../selectors';
9
+ import {
10
+ detectQuotaLimit,
11
+ confirmQuotaWarning,
12
+ } from './quota-detect';
13
+
14
+ type Scope = Page | Frame;
15
+
16
+ const UPLOAD_ACTION_SELECTOR = [
17
+ 'button',
18
+ 'input[type="submit"]',
19
+ 'input[type="button"]',
20
+ 'tdl-button',
21
+ 'tii-grn-button', // New UI: Submit/Resubmit button
22
+ '[role="button"]',
23
+ '[slot="accept-button"]',
24
+ '[part*="submit"]',
25
+ '[part*="upload"]',
26
+ '[data-px*="Submit"]',
27
+ '[data-px*="Upload"]',
28
+ '[with-data-px*="Submit"]',
29
+ '[with-data-px*="Upload"]',
30
+ ].join(', ');
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Error types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export class SubmissionQuotaLimitError extends Error {
37
+ constructor(message = 'Submission quota limit reached') {
38
+ super(message);
39
+ this.name = 'SubmissionQuotaLimitError';
40
+ }
41
+ }
42
+
43
+ export class ExistingSubmissionError extends Error {
44
+ constructor(message = 'Existing submission is already visible; resubmit mode is required') {
45
+ super(message);
46
+ this.name = 'ExistingSubmissionError';
47
+ }
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Submission-card check
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export async function hasSubmissionCard(scope: Scope): Promise<boolean> {
55
+ const cardSelector = [
56
+ SELECTORS.similarity.viewSubmissionButton,
57
+ SELECTORS.similarity.similarityDisplay,
58
+ SELECTORS.resubmit.resubmitButton,
59
+ 'a:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]',
60
+ 'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
61
+ ].join(', ');
62
+ return scope
63
+ .locator(cardSelector)
64
+ .first()
65
+ .isVisible({ timeout: 2000 })
66
+ .catch(() => false);
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // File chooser control
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function setDirectFileInput(scope: Scope, filePath: string): Promise<boolean> {
74
+ const input = scope.locator(SELECTORS.upload.fileInput).first();
75
+ if ((await input.count().catch(() => 0)) === 0) return false;
76
+ await input.setInputFiles(filePath);
77
+ return true;
78
+ }
79
+
80
+ async function clickUploadControlByText(
81
+ scope: Scope,
82
+ labels: string[],
83
+ ): Promise<boolean> {
84
+ return scope
85
+ .evaluate((textMatches: string[]) => {
86
+ const normalizedMatches = textMatches.map((value) => value.toLowerCase());
87
+ const roots: (Document | ShadowRoot)[] = [document];
88
+ const seen = new Set<Element>();
89
+
90
+ for (let i = 0; i < roots.length; i++) {
91
+ const root = roots[i];
92
+ for (const el of Array.from(root.querySelectorAll('*'))) {
93
+ if (seen.has(el)) continue;
94
+ seen.add(el);
95
+ if (el.shadowRoot) roots.push(el.shadowRoot);
96
+ }
97
+ }
98
+
99
+ const isVisible = (el: Element): boolean => {
100
+ if (!(el instanceof HTMLElement)) return false;
101
+ const style = window.getComputedStyle(el);
102
+ const rect = el.getBoundingClientRect();
103
+ return (
104
+ style.visibility !== 'hidden' &&
105
+ style.display !== 'none' &&
106
+ rect.width > 0 &&
107
+ rect.height > 0
108
+ );
109
+ };
110
+
111
+ const isDisabled = (el: Element): boolean =>
112
+ el instanceof HTMLElement &&
113
+ (
114
+ (el as HTMLButtonElement).disabled === true ||
115
+ el.getAttribute('disabled') !== null ||
116
+ el.getAttribute('aria-disabled') === 'true' ||
117
+ el.getAttribute('with-disabled') === 'true' ||
118
+ el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
119
+ );
120
+
121
+ const readText = (el: Element): string =>
122
+ [
123
+ (el as HTMLElement).innerText,
124
+ el.textContent,
125
+ el.getAttribute('aria-label'),
126
+ el.getAttribute('value'),
127
+ el.getAttribute('part'),
128
+ el.getAttribute('slot'),
129
+ ]
130
+ .filter(Boolean)
131
+ .join(' ')
132
+ .replace(/\s+/g, ' ')
133
+ .trim();
134
+
135
+ const clickElement = (el: Element): void => {
136
+ const shadowButton = el.shadowRoot?.querySelector(
137
+ 'button:not([disabled]), [role="button"]:not([disabled])',
138
+ );
139
+ const target =
140
+ shadowButton instanceof HTMLElement
141
+ ? shadowButton
142
+ : (el as HTMLElement);
143
+ target.scrollIntoView({ block: 'center', inline: 'center' });
144
+ target.click();
145
+ };
146
+
147
+ const candidates: Array<{ el: Element; score: number; top: number }> = [];
148
+ for (const root of roots) {
149
+ for (const el of Array.from(
150
+ root.querySelectorAll(
151
+ 'tii-ing-upload-button, tdl-button, button, [role="button"], input[type="button"]',
152
+ ),
153
+ )) {
154
+ if (!isVisible(el) || isDisabled(el)) continue;
155
+ const text = readText(el).toLowerCase();
156
+ const score = normalizedMatches.findIndex((match) => text.includes(match));
157
+ if (score < 0) continue;
158
+ candidates.push({
159
+ el,
160
+ score: 100 - score,
161
+ top: (el as HTMLElement).getBoundingClientRect().top,
162
+ });
163
+ }
164
+ }
165
+
166
+ candidates.sort((a, b) => b.score - a.score || b.top - a.top);
167
+ const target = candidates[0];
168
+ if (!target) return false;
169
+ clickElement(target.el);
170
+ return true;
171
+ }, labels)
172
+ .catch(() => false);
173
+ }
174
+
175
+ async function clickUploadControl(
176
+ page: Page,
177
+ scope: Scope,
178
+ filePath: string,
179
+ ): Promise<void> {
180
+ const browseLabels = [
181
+ 'browse files',
182
+ 'browse from your computer',
183
+ 'browse',
184
+ ];
185
+ const deviceLabels = [
186
+ 'your device',
187
+ 'upload from this device',
188
+ 'local drive',
189
+ ];
190
+
191
+ // New Turnitin UI: "Browse Files" opens a source menu, then "Your device"
192
+ // opens the native file chooser. Setting the hidden input before that menu
193
+ // is opened is unreliable because the upload component wires state on click.
194
+ const directChooserPromise = page
195
+ .waitForEvent('filechooser', { timeout: 7000 })
196
+ .catch(() => null);
197
+
198
+ const browse = scope.locator(SELECTORS.upload.browseButton).first();
199
+ let clickedBrowse = false;
200
+ if (await browse.isVisible({ timeout: 5000 }).catch(() => false)) {
201
+ await browse.click({ force: true });
202
+ clickedBrowse = true;
203
+ } else {
204
+ clickedBrowse = await clickUploadControlByText(scope, browseLabels);
205
+ }
206
+
207
+ if (clickedBrowse) {
208
+ const directChooser = await directChooserPromise;
209
+ if (directChooser) {
210
+ await directChooser.setFiles(filePath);
211
+ return;
212
+ }
213
+
214
+ await scope.waitForTimeout(1000).catch(() => {});
215
+ const menuChooserPromise = page
216
+ .waitForEvent('filechooser', { timeout: 10000 })
217
+ .catch(() => null);
218
+ const uploadFromDevice = scope
219
+ .locator(SELECTORS.upload.uploadFromDevice)
220
+ .first();
221
+ let clickedDevice = false;
222
+ if (await uploadFromDevice.isVisible({ timeout: 5000 }).catch(() => false)) {
223
+ await uploadFromDevice.click({ force: true });
224
+ clickedDevice = true;
225
+ } else {
226
+ clickedDevice = await clickUploadControlByText(scope, deviceLabels);
227
+ }
228
+
229
+ if (!clickedDevice) {
230
+ throw new Error('Upload source menu opened, but the Your device option was not found');
231
+ }
232
+
233
+ const menuChooser = await menuChooserPromise;
234
+ if (!menuChooser) {
235
+ throw new Error('File chooser did not open from Browse Files control');
236
+ }
237
+ await menuChooser.setFiles(filePath);
238
+ return;
239
+ }
240
+
241
+ if (await setDirectFileInput(scope, filePath)) return;
242
+
243
+ throw new Error('Browse Files control was not found in the upload form');
244
+ }
245
+
246
+ async function clickInitialSubmitButton(scope: Scope): Promise<boolean> {
247
+ const candidates = scope.locator(
248
+ 'tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]',
249
+ );
250
+ const count = Math.min(await candidates.count().catch(() => 0), 40);
251
+
252
+ for (let i = 0; i < count; i++) {
253
+ const candidate = candidates.nth(i);
254
+ if (!(await candidate.isVisible({ timeout: 500 }).catch(() => false))) {
255
+ continue;
256
+ }
257
+
258
+ const meta = await candidate
259
+ .evaluate((el: Element) => {
260
+ const text = [
261
+ (el as HTMLElement).innerText,
262
+ el.textContent,
263
+ el.getAttribute('aria-label'),
264
+ el.getAttribute('value'),
265
+ el.getAttribute('part'),
266
+ el.getAttribute('slot'),
267
+ ]
268
+ .filter(Boolean)
269
+ .join(' ')
270
+ .replace(/\s+/g, ' ')
271
+ .trim();
272
+ const disabled =
273
+ (el as HTMLButtonElement).disabled === true ||
274
+ el.getAttribute('disabled') !== null ||
275
+ el.getAttribute('aria-disabled') === 'true' ||
276
+ el.getAttribute('with-disabled') === 'true' ||
277
+ el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null;
278
+ return { text, disabled };
279
+ })
280
+ .catch(() => null);
281
+
282
+ if (!meta || meta.disabled) continue;
283
+ const normalized = meta.text.toLowerCase();
284
+ if (!/(^|\s)submit(\s|$)/i.test(meta.text)) continue;
285
+ if (
286
+ normalized.includes('resubmit') ||
287
+ normalized.includes('submit file') ||
288
+ normalized.includes('upload and preview') ||
289
+ normalized.includes('setting info') ||
290
+ normalized.includes('collapse details')
291
+ ) {
292
+ continue;
293
+ }
294
+
295
+ await candidate.click({ force: true });
296
+ return true;
297
+ }
298
+
299
+ return scope
300
+ .evaluate(() => {
301
+ const roots: (Document | ShadowRoot)[] = [document];
302
+ const seen = new Set<Element>();
303
+
304
+ for (let i = 0; i < roots.length; i++) {
305
+ const root = roots[i];
306
+ for (const el of Array.from(root.querySelectorAll('*'))) {
307
+ if (seen.has(el)) continue;
308
+ seen.add(el);
309
+ if (el.shadowRoot) roots.push(el.shadowRoot);
310
+ }
311
+ }
312
+
313
+ const isVisible = (el: Element): boolean => {
314
+ if (!(el instanceof HTMLElement)) return false;
315
+ const style = window.getComputedStyle(el);
316
+ const rect = el.getBoundingClientRect();
317
+ return (
318
+ style.visibility !== 'hidden' &&
319
+ style.display !== 'none' &&
320
+ rect.width > 0 &&
321
+ rect.height > 0
322
+ );
323
+ };
324
+
325
+ const isDisabled = (el: Element): boolean => {
326
+ if (!(el instanceof HTMLElement)) return true;
327
+ return (
328
+ (el as HTMLButtonElement).disabled === true ||
329
+ el.getAttribute('disabled') !== null ||
330
+ el.getAttribute('aria-disabled') === 'true' ||
331
+ el.getAttribute('with-disabled') === 'true' ||
332
+ el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
333
+ );
334
+ };
335
+
336
+ const readText = (el: Element): string =>
337
+ [
338
+ (el as HTMLElement).innerText,
339
+ el.textContent,
340
+ el.getAttribute('aria-label'),
341
+ el.getAttribute('value'),
342
+ el.getAttribute('part'),
343
+ el.getAttribute('slot'),
344
+ ]
345
+ .filter(Boolean)
346
+ .join(' ')
347
+ .replace(/\s+/g, ' ')
348
+ .trim();
349
+
350
+ const hasAncestorSignal = (el: Element): boolean => {
351
+ let current: Element | null = el;
352
+ const visited = new Set<Element>();
353
+ while (current && !visited.has(current)) {
354
+ visited.add(current);
355
+ const className = current.getAttribute('class') || '';
356
+ const slot = current.getAttribute('slot') || '';
357
+ const tag = current.tagName.toLowerCase();
358
+ if (
359
+ slot === 'submission-action' ||
360
+ className.includes('submission-action') ||
361
+ className.includes('submission-slot-container') ||
362
+ tag === 'tii-workflow-student-summary-panel-new' ||
363
+ tag === 'tii-workflow-lfw-student-show-assignment'
364
+ ) {
365
+ return true;
366
+ }
367
+
368
+ const parent: HTMLElement | null = current.parentElement;
369
+ if (parent) {
370
+ current = parent;
371
+ continue;
372
+ }
373
+ const root = current.getRootNode();
374
+ current = root instanceof ShadowRoot ? root.host : null;
375
+ }
376
+ return false;
377
+ };
378
+
379
+ const clickElement = (el: Element): void => {
380
+ const shadowButton = el.shadowRoot?.querySelector(
381
+ 'button:not([disabled]), [role="button"]:not([disabled])',
382
+ );
383
+ const target =
384
+ shadowButton instanceof HTMLElement
385
+ ? shadowButton
386
+ : (el as HTMLElement);
387
+ target.scrollIntoView({ block: 'center', inline: 'center' });
388
+ target.click();
389
+ };
390
+
391
+ const candidates: Array<{ el: Element; score: number; top: number; text: string }> = [];
392
+ for (const root of roots) {
393
+ for (const el of Array.from(
394
+ root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'),
395
+ )) {
396
+ if (!isVisible(el) || isDisabled(el)) continue;
397
+ const text = readText(el);
398
+ const normalized = text.toLowerCase();
399
+
400
+ if (!/(^|\s)submit(\s|$)/i.test(text)) continue;
401
+ if (
402
+ normalized.includes('resubmit') ||
403
+ normalized.includes('collapse details') ||
404
+ normalized.includes('setting info') ||
405
+ normalized.includes('submit file') ||
406
+ normalized.includes('upload and preview')
407
+ ) {
408
+ continue;
409
+ }
410
+
411
+ candidates.push({
412
+ el,
413
+ text,
414
+ score: hasAncestorSignal(el) ? 100 : 50,
415
+ top: (el as HTMLElement).getBoundingClientRect().top,
416
+ });
417
+ }
418
+ }
419
+
420
+ candidates.sort((a, b) => b.score - a.score || b.top - a.top);
421
+ const target = candidates[0];
422
+ if (!target) return false;
423
+ clickElement(target.el);
424
+ return true;
425
+ })
426
+ .catch(() => false);
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Submit button – tries labelled buttons then deep shadow DOM scan
431
+ // ---------------------------------------------------------------------------
432
+
433
+ async function collectUploadActionDiagnostics(
434
+ scope: Scope,
435
+ ): Promise<Array<Record<string, string | boolean>>> {
436
+ return scope
437
+ .evaluate((selector: string) => {
438
+ const roots: (Document | ShadowRoot)[] = [document];
439
+ const seen = new Set<Element>();
440
+
441
+ for (let i = 0; i < roots.length; i++) {
442
+ const root = roots[i];
443
+ for (const el of Array.from(root.querySelectorAll('*'))) {
444
+ if (seen.has(el)) continue;
445
+ seen.add(el);
446
+ if (el.shadowRoot) roots.push(el.shadowRoot);
447
+ }
448
+ }
449
+
450
+ const isVisible = (el: Element): boolean => {
451
+ if (!(el instanceof HTMLElement)) return false;
452
+ const style = window.getComputedStyle(el);
453
+ const rect = el.getBoundingClientRect();
454
+ return (
455
+ style.visibility !== 'hidden' &&
456
+ style.display !== 'none' &&
457
+ rect.width > 0 &&
458
+ rect.height > 0
459
+ );
460
+ };
461
+
462
+ const isDisabled = (el: Element): boolean => {
463
+ if (!(el instanceof HTMLElement)) return true;
464
+ return (
465
+ (el as HTMLButtonElement).disabled === true ||
466
+ el.getAttribute('disabled') !== null ||
467
+ el.getAttribute('aria-disabled') === 'true' ||
468
+ el.getAttribute('with-disabled') === 'true' ||
469
+ el.closest(
470
+ '[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
471
+ ) !== null
472
+ );
473
+ };
474
+
475
+ const rows: Array<Record<string, string | boolean>> = [];
476
+ for (const root of roots) {
477
+ for (const el of Array.from(root.querySelectorAll(selector))) {
478
+ if (!isVisible(el)) continue;
479
+ rows.push({
480
+ tag: el.tagName.toLowerCase(),
481
+ text: [
482
+ (el as HTMLElement).innerText,
483
+ el.textContent,
484
+ el.getAttribute('aria-label'),
485
+ el.getAttribute('value'),
486
+ el.getAttribute('part'),
487
+ el.getAttribute('slot'),
488
+ el.getAttribute('data-px'),
489
+ el.getAttribute('with-data-px'),
490
+ el.getAttribute('with-px-label'),
491
+ ]
492
+ .filter(Boolean)
493
+ .join(' ')
494
+ .replace(/\s+/g, ' ')
495
+ .trim()
496
+ .slice(0, 180),
497
+ disabled: isDisabled(el),
498
+ });
499
+ }
500
+ }
501
+ return rows.slice(0, 20);
502
+ }, UPLOAD_ACTION_SELECTOR)
503
+ .catch(() => []);
504
+ }
505
+
506
+ async function clickVisibleUploadActionByLocator(scope: Scope): Promise<string | null> {
507
+ const selectors = [
508
+ { selector: 'tdl-button:has-text("Upload and Preview")', phase: 'preview' },
509
+ { selector: 'button:has-text("Upload and Preview")', phase: 'preview' },
510
+ { selector: 'tdl-button[part="submit-button"]', phase: 'preview' },
511
+ { selector: 'tdl-button:has-text("Confirm and Submit")', phase: 'final' },
512
+ { selector: 'button:has-text("Confirm and Submit")', phase: 'final' },
513
+ { selector: 'tdl-button:has-text("Submit File")', phase: 'final' },
514
+ { selector: 'button:has-text("Submit File")', phase: 'final' },
515
+ { selector: 'tdl-button:has-text("Submit")', phase: 'final' },
516
+ { selector: 'button:has-text("Submit")', phase: 'final' },
517
+ { selector: 'tii-grn-button:has-text("Submit")', phase: 'final' },
518
+ { selector: '[slot="accept-button"]:has-text("Submit")', phase: 'final' },
519
+ ];
520
+
521
+ for (const { selector, phase } of selectors) {
522
+ const locator = scope.locator(selector);
523
+ const count = Math.min(await locator.count().catch(() => 0), 8);
524
+ for (let index = 0; index < count; index++) {
525
+ const target = locator.nth(index);
526
+ if (!(await target.isVisible({ timeout: 500 }).catch(() => false))) {
527
+ continue;
528
+ }
529
+
530
+ const meta = await target
531
+ .evaluate((el: Element) => {
532
+ const text = [
533
+ (el as HTMLElement).innerText,
534
+ el.textContent,
535
+ el.getAttribute('aria-label'),
536
+ el.getAttribute('part'),
537
+ el.getAttribute('slot'),
538
+ ]
539
+ .filter(Boolean)
540
+ .join(' ')
541
+ .replace(/\s+/g, ' ')
542
+ .trim();
543
+ const disabled =
544
+ (el as HTMLButtonElement).disabled === true ||
545
+ el.getAttribute('disabled') !== null ||
546
+ el.getAttribute('aria-disabled') === 'true' ||
547
+ el.getAttribute('with-disabled') === 'true' ||
548
+ el.closest(
549
+ '[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
550
+ ) !== null;
551
+ return { text, disabled };
552
+ })
553
+ .catch(() => null);
554
+
555
+ if (!meta || meta.disabled) continue;
556
+ const normalized = meta.text.toLowerCase();
557
+ if (
558
+ normalized.includes('cancel') ||
559
+ normalized.includes('close') ||
560
+ normalized.includes('help') ||
561
+ normalized.includes('collapse') ||
562
+ normalized.includes('setting info') ||
563
+ normalized.includes('assignment details') ||
564
+ normalized.includes('view submission')
565
+ ) {
566
+ continue;
567
+ }
568
+
569
+ await target.scrollIntoViewIfNeeded().catch(() => {});
570
+ await target.click({ force: true, timeout: 5000 }).catch(async () => {
571
+ const box = await target.boundingBox().catch(() => null);
572
+ if (!box) throw new Error(`Upload action click failed for ${selector}`);
573
+ const ownerPage =
574
+ typeof (scope as any).page === 'function'
575
+ ? (scope as Frame).page()
576
+ : (scope as Page);
577
+ await ownerPage.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
578
+ });
579
+ return `${phase}:${meta.text || selector}`;
580
+ }
581
+ }
582
+
583
+ return null;
584
+ }
585
+
586
+ async function clickVisibleSubmit(
587
+ scope: Scope,
588
+ page: Page,
589
+ ): Promise<boolean> {
590
+ const deadline = Date.now() + 180000;
591
+ while (Date.now() < deadline) {
592
+ // Accept quota warnings that may appear at each iteration
593
+ await confirmQuotaWarning(page);
594
+
595
+ const visibleText = await scopeHasVisibleDeepText(scope, [
596
+ 'we are creating a preview',
597
+ 'please wait for us to process a preview',
598
+ ]);
599
+ if (visibleText) {
600
+ await scope.waitForTimeout(2000).catch(() => {});
601
+ continue;
602
+ }
603
+
604
+ const locatorClicked = await clickVisibleUploadActionByLocator(scope).catch(() => null);
605
+ if (locatorClicked) {
606
+ await scope.waitForTimeout(2500).catch(() => {});
607
+ if (/^(preview|continue):/i.test(locatorClicked)) {
608
+ await scope.waitForTimeout(1000).catch(() => {});
609
+ } else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
610
+ return true;
611
+ }
612
+ }
613
+
614
+ const clicked = await scope
615
+ .evaluate((selector: string) => {
616
+ const roots: (Document | ShadowRoot)[] = [document];
617
+ const seen = new Set<Element>();
618
+ for (let i = 0; i < roots.length; i++) {
619
+ const root = roots[i];
620
+ for (const el of Array.from(root.querySelectorAll('*'))) {
621
+ if (seen.has(el)) continue;
622
+ seen.add(el);
623
+ if (el.shadowRoot) roots.push(el.shadowRoot);
624
+ }
625
+ }
626
+
627
+ const isVisible = (el: Element): boolean => {
628
+ if (!(el instanceof HTMLElement)) return false;
629
+ const style = window.getComputedStyle(el);
630
+ const rect = el.getBoundingClientRect();
631
+ return (
632
+ style.visibility !== 'hidden' &&
633
+ style.display !== 'none' &&
634
+ rect.width > 0 &&
635
+ rect.height > 0
636
+ );
637
+ };
638
+
639
+ const isDisabled = (el: Element): boolean => {
640
+ if (!(el instanceof HTMLElement)) return true;
641
+ return (
642
+ (el as HTMLButtonElement).disabled === true ||
643
+ el.getAttribute('disabled') !== null ||
644
+ el.getAttribute('aria-disabled') === 'true' ||
645
+ el.getAttribute('with-disabled') === 'true' ||
646
+ el.closest(
647
+ '[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
648
+ ) !== null
649
+ );
650
+ };
651
+
652
+ const clickElement = (el: Element): void => {
653
+ const shadowButton = el.shadowRoot?.querySelector(
654
+ 'button:not([disabled]), input[type="submit"]:not([disabled])',
655
+ );
656
+ const target =
657
+ shadowButton instanceof HTMLElement
658
+ ? shadowButton
659
+ : (el as HTMLElement);
660
+ target.scrollIntoView({ block: 'center', inline: 'center' });
661
+ target.click();
662
+ };
663
+
664
+ const readText = (el: Element): string =>
665
+ [
666
+ (el as HTMLElement).innerText,
667
+ el.textContent,
668
+ el.getAttribute('aria-label'),
669
+ el.getAttribute('value'),
670
+ el.getAttribute('part'),
671
+ el.getAttribute('slot'),
672
+ el.getAttribute('data-px'),
673
+ el.getAttribute('with-data-px'),
674
+ el.getAttribute('with-px-label'),
675
+ ]
676
+ .filter(Boolean)
677
+ .join(' ')
678
+ .replace(/\s+/g, ' ')
679
+ .trim();
680
+
681
+ const priority = (text: string): { score: number; phase: string } => {
682
+ const normalized = text.toLowerCase();
683
+ if (
684
+ normalized.includes('resubmit') ||
685
+ normalized.includes('cancel') ||
686
+ normalized.includes('close') ||
687
+ normalized.includes('expand') ||
688
+ normalized.includes('help') ||
689
+ normalized.includes('setting info') ||
690
+ normalized.includes('view submission') ||
691
+ normalized.includes('assignment details')
692
+ ) {
693
+ return { score: 0, phase: '' };
694
+ }
695
+ if (normalized.includes('continue to file upload')) {
696
+ return { score: 120, phase: 'continue' };
697
+ }
698
+ if (normalized.includes('upload and preview')) {
699
+ return { score: 110, phase: 'preview' };
700
+ }
701
+ if (normalized.includes('confirm and submit')) {
702
+ return { score: 100, phase: 'final' };
703
+ }
704
+ if (/(^|\s)submit file(\s|$)/i.test(normalized)) {
705
+ return { score: 95, phase: 'final' };
706
+ }
707
+ if (/(^|\s)submit(\s|$)/i.test(normalized)) {
708
+ return { score: 90, phase: 'final' };
709
+ }
710
+ if (/(^|\s)confirm(\s|$)/i.test(normalized)) {
711
+ return { score: 80, phase: 'final' };
712
+ }
713
+ if (/(^|\s)upload(\s|$)/i.test(normalized)) {
714
+ return { score: 70, phase: 'preview' };
715
+ }
716
+ if (/(^|\s)continue(\s|$)/i.test(normalized)) {
717
+ return { score: 60, phase: 'continue' };
718
+ }
719
+ if (normalized.includes('accept-button')) {
720
+ return { score: 50, phase: 'continue' };
721
+ }
722
+ return { score: 0, phase: '' };
723
+ };
724
+
725
+ const candidates: Array<{
726
+ el: Element;
727
+ text: string;
728
+ score: number;
729
+ phase: string;
730
+ top: number;
731
+ }> = [];
732
+ for (const root of roots) {
733
+ for (const el of Array.from(root.querySelectorAll(selector))) {
734
+ if (!isVisible(el) || isDisabled(el)) continue;
735
+ const text = readText(el);
736
+ const { score, phase } = priority(text);
737
+ if (score > 0) {
738
+ candidates.push({
739
+ el,
740
+ text,
741
+ score,
742
+ phase,
743
+ top: (el as HTMLElement).getBoundingClientRect().top,
744
+ });
745
+ }
746
+ }
747
+ }
748
+
749
+ candidates.sort((a, b) => b.score - a.score || b.top - a.top);
750
+ const target = candidates[0];
751
+ if (!target) return '';
752
+ clickElement(target.el);
753
+ return `${target.phase}:${target.text}`;
754
+ }, UPLOAD_ACTION_SELECTOR)
755
+ .catch(() => '');
756
+
757
+ if (clicked) {
758
+ await scope.waitForTimeout(2500).catch(() => {});
759
+ if (/^(preview|continue):/i.test(clicked)) {
760
+ await scope.waitForTimeout(1000).catch(() => {});
761
+ } else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
762
+ return true;
763
+ }
764
+ }
765
+
766
+ // Handle "Preview Unavailable" state
767
+ if (await scopeHasVisibleDeepText(scope, ['preview unavailable'])) {
768
+ const ownerPage =
769
+ typeof (scope as any).page === 'function'
770
+ ? (scope as Frame).page()
771
+ : (scope as Page);
772
+ await ownerPage.mouse.click(890, 670).catch(() => {});
773
+ await scope.waitForTimeout(3500).catch(() => {});
774
+ if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) return true;
775
+ }
776
+
777
+ await scope.waitForTimeout(1000).catch(() => {});
778
+ }
779
+
780
+ return false;
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // Public: uploadFile
785
+ // ---------------------------------------------------------------------------
786
+
787
+ /**
788
+ * Upload a file to an assignment that has no prior submission.
789
+ *
790
+ * Steps:
791
+ * 1. Assert quota is available
792
+ * 2. Find browse/upload form scope
793
+ * 3. Click Browse Files / set file via file chooser
794
+ * 4. Click Upload and Preview
795
+ * 5. Handle Preview Unavailable
796
+ * 6. Click Submit
797
+ * 7. Wait for success toast and submission card
798
+ */
799
+ export async function uploadFile(
800
+ page: Page,
801
+ filePath: string,
802
+ options: { skipExistingCheck?: boolean } = {},
803
+ ): Promise<void> {
804
+ // Check for existing submission
805
+ if (!options.skipExistingCheck) {
806
+ const existingScope = await findAssignmentScope(
807
+ page,
808
+ [
809
+ 'button.link-button[aria-label*="View submission"]',
810
+ 'a[aria-label*="View submission"]',
811
+ 'button:has-text("Resubmit")',
812
+ 'tdl-button:has-text("Resubmit")',
813
+ 'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
814
+ ].join(', '),
815
+ 1000,
816
+ );
817
+ if (existingScope && (await hasSubmissionCard(existingScope))) {
818
+ throw new ExistingSubmissionError();
819
+ }
820
+ }
821
+
822
+ // Assert quota is available
823
+ const quotaLimit = await detectQuotaLimit(page);
824
+ if (quotaLimit) {
825
+ throw new SubmissionQuotaLimitError(quotaLimit.message);
826
+ }
827
+
828
+ let workflowScope: Scope = page;
829
+ // Check if we need to click the initial "Submit" button to open the upload form
830
+ const isUploadFormVisible = await page
831
+ .locator(SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer)
832
+ .first()
833
+ .isVisible({ timeout: 2000 })
834
+ .catch(() => false);
835
+
836
+ if (!isUploadFormVisible) {
837
+ logger.info('Upload form not visible. Searching for initial Submit button...');
838
+ const initialSubmitSelectors = [
839
+ 'tii-workflow-lfw-student-show-assignment tii-workflow-student-summary-panel-new [slot="submission-action"] tii-grn-button:has-text("Submit")',
840
+ 'tii-workflow-lfw-student-show-assignment .submission-action tii-grn-button:has-text("Submit")',
841
+ 'tii-workflow-student-summary-panel-new tii-grn-button:has-text("Submit")',
842
+ 'div[slot="submission-action"].submission-action tii-grn-button:has-text("Submit")',
843
+ '.submission-slot-container tii-grn-button:has-text("Submit")',
844
+ 'tii-workflow-student-summary-panel-new tii-grn-button',
845
+ 'tii-grn-button:has-text("Submit")',
846
+ 'button:has-text("Submit")',
847
+ 'tdl-button:has-text("Submit")',
848
+ ];
849
+
850
+ let clickedInitialSubmit = false;
851
+ const initialSubmitScope =
852
+ (await findAssignmentScope(page, initialSubmitSelectors.join(', '), 3000)) ||
853
+ (await findScopeByDeepText(
854
+ page,
855
+ ['submission settings', 'resubmissions are allowed', 'submit'],
856
+ 3000,
857
+ )) ||
858
+ page;
859
+ workflowScope = initialSubmitScope;
860
+
861
+ for (const selector of initialSubmitSelectors) {
862
+ const btn = initialSubmitScope.locator(selector).first();
863
+ if (await btn.isVisible({ timeout: 1500 }).catch(() => false)) {
864
+ logger.info(`Clicking initial Submit button: ${selector}`);
865
+ await btn.click({ force: true });
866
+ clickedInitialSubmit = true;
867
+ await page.waitForTimeout(3000);
868
+ break;
869
+ }
870
+ }
871
+
872
+ if (!clickedInitialSubmit) {
873
+ const clickedByDom = await clickInitialSubmitButton(initialSubmitScope);
874
+
875
+ if (clickedByDom) {
876
+ logger.info('Initial Submit button clicked via deep shadow DOM fallback');
877
+ await page.waitForTimeout(3000);
878
+ } else {
879
+ logger.warn('Initial Submit button not found or could not be clicked. Proceeding to find upload form anyway...');
880
+ }
881
+ }
882
+ }
883
+
884
+ // Find upload form
885
+ // New UI: div.upload-step or tii-ing-dropzone
886
+ // Old UI: tdl-button[part="upload-form-container-button"] or input[type="file"]
887
+ let scope: Scope | null =
888
+ (await findAssignmentScope(
889
+ page,
890
+ SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer,
891
+ 5000,
892
+ )) ||
893
+ (await findScopeByDeepText(
894
+ page,
895
+ ['browse files', 'drag and drop file', 'drag and drop your file', 'your work'],
896
+ 30000,
897
+ ));
898
+
899
+ if (!scope) {
900
+ logger.warn('Upload form scope was not resolved; trying workflow scope upload controls');
901
+ scope = workflowScope;
902
+ }
903
+
904
+ logger.info('Uploading file', { filePath });
905
+ await clickUploadControl(page, scope, filePath);
906
+ await confirmQuotaWarning(page);
907
+ await page.waitForTimeout(4000);
908
+
909
+ // Re-resolve scope for submit button
910
+ scope =
911
+ (await findAssignmentScope(page, SELECTORS.upload.submitButton, 5000)) ||
912
+ (await findScopeByDeepText(
913
+ page,
914
+ ['upload and preview', 'submit file', 'preview unavailable'],
915
+ 5000,
916
+ )) ||
917
+ scope;
918
+
919
+ const clickedSubmit = await clickVisibleSubmit(scope, page);
920
+ if (!clickedSubmit) {
921
+ logger.warn('Upload action candidates after file selection', {
922
+ candidates: await collectUploadActionDiagnostics(scope),
923
+ });
924
+ throw new Error(
925
+ 'Upload file was selected, but no Upload/Confirm/Submit button was found',
926
+ );
927
+ }
928
+
929
+ await page.waitForTimeout(2500);
930
+ logger.info('File upload completed');
931
+ }
src/engine/steps/viewer-similarity.ts ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, Frame } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import { readScopeDeepText } from '../selectors';
4
+
5
+ type Scope = Page | Frame;
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Parsing helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Extract a similarity percentage from text.
13
+ *
14
+ * Handles multiple formats found across Turnitin UI versions:
15
+ * - "45% Overall Similarity"
16
+ * - "Similarity: 45%"
17
+ * - "Overall Similarity 45 %"
18
+ * - Plain "45" or "45%" (when extracted directly from a score element)
19
+ */
20
+ function parseSimilarityPercent(text: string): number | null {
21
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim();
22
+
23
+ // Pattern 1: "45% Overall Similarity"
24
+ const labelled = normalized.match(
25
+ /(\d{1,3})\s*%\s*Overall Similarity/i,
26
+ );
27
+ // Pattern 2: "Similarity: 45%" or "Overall Similarity: 45%"
28
+ const generic = normalized.match(
29
+ /(?:Overall\s+)?Similarity[:\s]+(\d{1,3})\s*%/i,
30
+ );
31
+ // Pattern 3: "Overall Similarity 45 %" (label before value)
32
+ const reversed = normalized.match(
33
+ /Overall Similarity\s+(\d{1,3})\s*%/i,
34
+ );
35
+ // Pattern 4: Plain number from a score element — "45" or "45%"
36
+ const plain = normalized.match(/^(\d{1,3})\s*%?$/);
37
+
38
+ const value = Number(
39
+ labelled?.[1] ?? generic?.[1] ?? reversed?.[1] ?? plain?.[1] ?? NaN,
40
+ );
41
+ if (!Number.isFinite(value)) return null;
42
+ return Math.max(0, Math.min(100, value));
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // CSS selectors for the similarity score in the report viewer
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const SCORE_SELECTORS = [
50
+ // Primary — Turnitin viewer overview score
51
+ '.tii-SimilarityHeader__heading-content .tii-OverviewScore__Value',
52
+ '.tii-OverviewScore__Value',
53
+ 'span.tii-OverviewScore__Value',
54
+ // Heading area with live region
55
+ 'h1[aria-live="polite"] span',
56
+ 'h1[aria-live="polite"]',
57
+ // Alternative UI variants
58
+ '.tii-SimilarityReportPanelHeader__OverviewScore span',
59
+ '.similarity-score',
60
+ '.overall-similarity-score',
61
+ '[data-testid="similarity-score"]',
62
+ // Badge-style display
63
+ 'span[part="tii-grn-badge-label"]',
64
+ ];
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Public API
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Read the current overall similarity score from the Turnitin report viewer.
72
+ *
73
+ * Handles:
74
+ * - Multiple CSS selector strategies for different Turnitin UI versions
75
+ * - Shadow DOM deep text fallback
76
+ * - Automatic retry with short wait to handle post-filter score updates
77
+ *
78
+ * This is used both as a fallback for assignment pages that still show
79
+ * similarity as pending, and as the authoritative read after filter
80
+ * application (where the displayed score may differ from the pre-filter value).
81
+ */
82
+ export async function readViewerSimilarityPercent(
83
+ page: Scope,
84
+ ): Promise<number | null> {
85
+ // Allow up to 3 attempts with a short stabilization wait between each.
86
+ // After filters are applied the score element may take a moment to update.
87
+ const MAX_READ_ATTEMPTS = 3;
88
+
89
+ for (let attempt = 1; attempt <= MAX_READ_ATTEMPTS; attempt++) {
90
+ // Small wait for DOM to stabilize (especially after filter application)
91
+ if (attempt > 1) {
92
+ await page.waitForTimeout(2000);
93
+ }
94
+
95
+ // --- Strategy 1: CSS selector scan ---
96
+ for (const selector of SCORE_SELECTORS) {
97
+ const element = page.locator(selector).first();
98
+
99
+ const rawText = await element
100
+ .textContent({ timeout: 2000 })
101
+ .catch(() => null);
102
+
103
+ if (rawText !== null && rawText.trim() !== '') {
104
+ // Try parsing as-is first (may already contain "Overall Similarity")
105
+ let parsed = parseSimilarityPercent(rawText.trim());
106
+ // Fallback: append context so the labelled regex can match plain numbers
107
+ if (parsed === null) {
108
+ parsed = parseSimilarityPercent(`${rawText.trim()} Overall Similarity`);
109
+ }
110
+
111
+ if (parsed !== null) {
112
+ logger.info('Read similarity score from viewer selector', {
113
+ selector,
114
+ rawText: rawText.trim(),
115
+ similarityPercent: parsed,
116
+ attempt,
117
+ });
118
+ return parsed;
119
+ }
120
+ }
121
+ }
122
+
123
+ // --- Strategy 2: Deep text scan (traverses shadow DOM) ---
124
+ const deepText = await readScopeDeepText(page);
125
+ if (deepText) {
126
+ const parsed = parseSimilarityPercent(deepText);
127
+ if (parsed !== null) {
128
+ logger.info('Read similarity score from viewer deep text', {
129
+ similarityPercent: parsed,
130
+ attempt,
131
+ });
132
+ return parsed;
133
+ }
134
+ }
135
+
136
+ if (attempt < MAX_READ_ATTEMPTS) {
137
+ logger.debug(`Similarity score not found on attempt ${attempt}/${MAX_READ_ATTEMPTS}; retrying`);
138
+ }
139
+ }
140
+
141
+ logger.warn('Could not read similarity score from viewer after all attempts');
142
+ return null;
143
+ }
src/engine/steps/viewer.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, BrowserContext, Frame, Locator } from 'playwright';
2
+ import { logger } from '../../utils/logger';
3
+ import {
4
+ SELECTORS,
5
+ deepClickByText,
6
+ resolveAssignmentScope,
7
+ } from '../selectors';
8
+
9
+ type ViewerScope = Page | Frame;
10
+
11
+ function ownerPage(scope: ViewerScope): Page {
12
+ return typeof (scope as any).page === 'function'
13
+ ? (scope as Frame).page()
14
+ : (scope as Page);
15
+ }
16
+
17
+ function isReportSubmissionUrl(url: string): boolean {
18
+ return (
19
+ url.includes('reports.integrity.turnitin.com') &&
20
+ /submission-viewer|\/submission\//i.test(url)
21
+ );
22
+ }
23
+
24
+ function findReportViewerScope(context: BrowserContext): ViewerScope | null {
25
+ for (const candidatePage of context.pages()) {
26
+ if (isReportSubmissionUrl(candidatePage.url())) {
27
+ return candidatePage;
28
+ }
29
+
30
+ const reportFrame = candidatePage
31
+ .frames()
32
+ .find((frame) => isReportSubmissionUrl(frame.url()));
33
+ if (reportFrame) {
34
+ return reportFrame;
35
+ }
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Click the submission title to open the report viewer.
43
+ * Returns the viewer page after it has navigated to reports.integrity.turnitin.com.
44
+ */
45
+ export async function openReportViewerPage(
46
+ page: Page,
47
+ context: BrowserContext,
48
+ similarityScope?: ViewerScope,
49
+ similarityLocator?: Locator,
50
+ ): Promise<ViewerScope> {
51
+ const scope = similarityScope || (await resolveAssignmentScope(page));
52
+
53
+ const titleButton = scope
54
+ .locator(SELECTORS.viewer.titleButton)
55
+ .first();
56
+
57
+ const specificTargetVisible = similarityLocator
58
+ ? await similarityLocator.isVisible({ timeout: 3000 }).catch(() => false)
59
+ : false;
60
+ const clickTarget = specificTargetVisible
61
+ ? similarityLocator!
62
+ : (await titleButton.isVisible({ timeout: 3000 }).catch(() => false))
63
+ ? titleButton
64
+ : null;
65
+
66
+ const [newPage] = await Promise.all([
67
+ context
68
+ .waitForEvent('page', { timeout: 30000 })
69
+ .catch(() => null),
70
+ clickTarget
71
+ ? clickTarget.click({ force: true }).catch(async () => {
72
+ await deepClickByText(scope, ['view submission']);
73
+ })
74
+ : deepClickByText(scope, ['view submission']),
75
+ ]);
76
+
77
+ // BUG-6 FIX: Verify the captured page is actually the report viewer before
78
+ // waiting for its load state. context.waitForEvent('page') can fire for any
79
+ // new page (ads, redirects), so we must not blindly wait 30s on a wrong page.
80
+ let viewerScope: ViewerScope;
81
+ if (newPage && isReportSubmissionUrl(newPage.url())) {
82
+ // Correct page — wait for it to load normally
83
+ viewerScope = newPage;
84
+ await (viewerScope as Page)
85
+ .waitForLoadState('domcontentloaded', { timeout: 60000 })
86
+ .catch(() => {});
87
+ } else {
88
+ // Wrong page or no page event — scan existing pages/frames immediately
89
+ if (newPage) {
90
+ logger.warn('waitForEvent(page) captured a non-viewer page; scanning context for viewer', {
91
+ capturedUrl: newPage.url(),
92
+ });
93
+ }
94
+ // Give Turnitin a short moment to open the real viewer page
95
+ const scopeOwner = typeof (scope as any).page === 'function'
96
+ ? (scope as Frame).page()
97
+ : (scope as Page);
98
+ await scopeOwner.waitForTimeout(3000).catch(() => {});
99
+ const existing = findReportViewerScope(context);
100
+ viewerScope = existing ||
101
+ (typeof (scope as any).page === 'function'
102
+ ? (scope as Frame).page()
103
+ : (scope as Page));
104
+ }
105
+
106
+ const deadline = Date.now() + 30000;
107
+ while (
108
+ Date.now() < deadline &&
109
+ !isReportSubmissionUrl(viewerScope.url())
110
+ ) {
111
+ const existing = findReportViewerScope(context);
112
+ if (existing) {
113
+ viewerScope = existing;
114
+ break;
115
+ }
116
+ await ownerPage(viewerScope).waitForTimeout(500).catch(() => {});
117
+ }
118
+
119
+ await ownerPage(viewerScope).waitForTimeout(2500);
120
+
121
+ const viewerUrl = viewerScope.url();
122
+ logger.info('Report viewer opened', { viewerUrl });
123
+
124
+ if (!isReportSubmissionUrl(viewerUrl)) {
125
+ throw new Error(
126
+ `Report viewer did not open a submission route correctly. Current URL: ${viewerUrl}`,
127
+ );
128
+ }
129
+
130
+ return viewerScope;
131
+ }
132
+
133
+ export async function openReportViewer(
134
+ page: Page,
135
+ context: BrowserContext,
136
+ ): Promise<string> {
137
+ const viewerPage = await openReportViewerPage(page, context);
138
+ return viewerPage.url();
139
+ }
src/engine/turnitin.ts ADDED
@@ -0,0 +1,1036 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Page, type BrowserContext, type Frame } from 'playwright';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { config } from '../config';
5
+ import { logger } from '../utils/logger';
6
+ import { getBrowser } from '../worker/browser-pool';
7
+ import { loginToTurnitin } from './steps/login';
8
+ import { navigateToAssignment } from './steps/navigate';
9
+ import { dropClassByTitle } from './steps/class-management';
10
+ import { uploadFile, SubmissionQuotaLimitError } from './steps/upload';
11
+ import { resubmitFile } from './steps/resubmit';
12
+ import {
13
+ detectQuotaLimit,
14
+ runQuotaCheck,
15
+ } from './steps/quota-detect';
16
+ import { waitForSimilarity, type SimilarityResult } from './steps/similarity';
17
+ import { openReportViewerPage } from './steps/viewer';
18
+ import { readViewerSimilarityPercent } from './steps/viewer-similarity';
19
+ import { readSubmissionDetails, type SubmissionDetails } from './steps/submission-details';
20
+ import { detectSubmissionState } from './steps/submission-state';
21
+ import { applyFilters, validateFilters, hasActiveFilters, type FilterOptions } from './steps/filters';
22
+ import { downloadPdf } from './steps/download';
23
+ import { runLegacyTurnitinJob } from './legacy';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type { FilterOptions } from './steps/filters';
30
+ export type TurnitinUiVariant = 'modern_lti' | 'legacy_carta';
31
+ export const MODERN_ONE_POOL_KEY = 'modern_one';
32
+
33
+ export interface RunTurnitinJobInput {
34
+ account: {
35
+ id: string;
36
+ email: string;
37
+ password: string;
38
+ quotaLimit?: number | null;
39
+ quotaRemaining?: number | null;
40
+ };
41
+ assignmentTarget: {
42
+ targetUrl: string;
43
+ classTitle: string;
44
+ assignmentTitle?: string | null;
45
+ assignmentLaunchUrl?: string | null;
46
+ uiVariant?: TurnitinUiVariant;
47
+ accountPoolKey?: string | null;
48
+ };
49
+ inputFilePath: string;
50
+ inputFileName?: string;
51
+ inputFileSize?: number;
52
+ outputDir: string;
53
+ mode: 'upload' | 'resubmit' | 'quota_check';
54
+ filters: FilterOptions;
55
+ storageStatePath?: string;
56
+ /**
57
+ * When set, the engine will skip steps that have already been completed and
58
+ * resume from the step after this one. For example, if `resumeAfterStep` is
59
+ * `'submitted'`, the engine will skip upload/resubmit and jump straight to
60
+ * waiting for the similarity score.
61
+ */
62
+ resumeAfterStep?: string;
63
+ /**
64
+ * Existing report viewer URL from a previous attempt. When the previous
65
+ * attempt already reached the viewer, retries reopen this URL directly
66
+ * instead of uploading/resubmitting the same file again.
67
+ */
68
+ resumeViewerUrl?: string | null;
69
+ attemptCount?: number;
70
+ onEvent?: (event: {
71
+ level: 'info' | 'warning' | 'error';
72
+ step: string;
73
+ message: string;
74
+ metadata?: Record<string, unknown>;
75
+ }) => Promise<void>;
76
+ }
77
+
78
+ export interface RunTurnitinJobResult {
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 };
86
+ quotaCooldown?: { message: string; nextRetryAt: string; submissionCount?: number };
87
+ permanentLimit?: {
88
+ message: string;
89
+ submissionCount?: number;
90
+ dropClassResult?: {
91
+ attempted: boolean;
92
+ dropped: boolean;
93
+ reason?: string;
94
+ };
95
+ };
96
+ submittedAt?: string;
97
+ submissionCount?: number;
98
+ /** The last step that completed successfully – persisted so retries can skip. */
99
+ lastCompletedStep?: string;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // EULA-blocked error (for retry logic)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ class EulaBlockedError extends Error {
107
+ constructor(message = 'Assignment launch is blocked by EULA') {
108
+ super(message);
109
+ this.name = 'EulaBlockedError';
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Event emitter helper
115
+ // ---------------------------------------------------------------------------
116
+
117
+ async function emit(
118
+ onEvent: RunTurnitinJobInput['onEvent'],
119
+ level: 'info' | 'warning' | 'error',
120
+ step: string,
121
+ message: string,
122
+ metadata?: Record<string, unknown>,
123
+ ): Promise<void> {
124
+ if (onEvent) {
125
+ await onEvent({ level, step, message, metadata }).catch(() => {});
126
+ }
127
+ if (level === 'error') {
128
+ logger.error(message, { step, ...metadata });
129
+ } else if (level === 'warning') {
130
+ logger.warn(message, { step, ...metadata });
131
+ } else {
132
+ logger.info(message, { step, ...metadata });
133
+ }
134
+ }
135
+
136
+ function compactEngineErrorMessage(message: string): string {
137
+ const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
138
+ if (/locator\.waitFor: Timeout/i.test(firstLine)) {
139
+ const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
140
+ return selector
141
+ ? `Turnitin page element did not appear in time: ${selector}`
142
+ : 'Turnitin page element did not appear in time.';
143
+ }
144
+ if (/Call log:/i.test(message)) return firstLine;
145
+ return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Step ordering — used to determine whether to skip already-completed steps
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const STEP_ORDER = [
153
+ 'login',
154
+ 'navigate',
155
+ 'quota_check',
156
+ 'upload', // covers both upload and resubmit
157
+ 'submitted',
158
+ 'similarity',
159
+ 'viewer',
160
+ 'filters',
161
+ 'download',
162
+ ] as const;
163
+
164
+ /**
165
+ * Returns true when `completedStep` is at or after `targetStep` in the
166
+ * pipeline, meaning `targetStep` can safely be skipped.
167
+ */
168
+ function isStepCompleted(completedStep: string | undefined, targetStep: string): boolean {
169
+ if (!completedStep) return false;
170
+ const completedIdx = STEP_ORDER.indexOf(completedStep as any);
171
+ const targetIdx = STEP_ORDER.indexOf(targetStep as any);
172
+ if (completedIdx < 0 || targetIdx < 0) return false;
173
+ return completedIdx >= targetIdx;
174
+ }
175
+
176
+ function decodeFileNameForCompare(fileName: string): string {
177
+ try {
178
+ return decodeURIComponent(fileName);
179
+ } catch {
180
+ return fileName;
181
+ }
182
+ }
183
+
184
+ function normalizeFileNameForCompare(fileName: string | undefined): string {
185
+ return decodeFileNameForCompare(path.basename(String(fileName || '')))
186
+ .normalize('NFKC')
187
+ .replace(/[\u200B-\u200D\uFEFF]/g, '')
188
+ .replace(/\s+/g, ' ')
189
+ .trim()
190
+ .toLowerCase();
191
+ }
192
+
193
+ function compactFileNameForCompare(fileName: string | undefined): string {
194
+ return normalizeFileNameForCompare(fileName).replace(/\s+/g, '');
195
+ }
196
+
197
+ function submissionDetailsMatchInput(
198
+ details: SubmissionDetails | null,
199
+ inputFileName: string,
200
+ inputFileSize?: number,
201
+ ): boolean {
202
+ if (!details?.fileName || typeof inputFileSize !== 'number') return false;
203
+ const detailName = normalizeFileNameForCompare(details.fileName);
204
+ const expectedName = normalizeFileNameForCompare(inputFileName);
205
+ const detailCompactName = compactFileNameForCompare(details.fileName);
206
+ const expectedCompactName = compactFileNameForCompare(inputFileName);
207
+ return Boolean(
208
+ details.fileSize === inputFileSize &&
209
+ detailName &&
210
+ expectedName &&
211
+ (detailName === expectedName || detailCompactName === expectedCompactName),
212
+ );
213
+ }
214
+
215
+ function getScopeOwnerPage(scope: Page | Frame): Page {
216
+ return typeof (scope as any).page === 'function'
217
+ ? (scope as Frame).page()
218
+ : (scope as Page);
219
+ }
220
+
221
+ const MODERN_VIEWER_READY_SELECTOR = [
222
+ 'tii-sws-tab-button#tab-similarity',
223
+ '#tab-similarity',
224
+ '.tii-SimilarityReportPanel[aria-hidden="false"]',
225
+ '.tii-OverviewScore__Value',
226
+ 'tdl-button[with-data-px="SettingsClicked"]',
227
+ '.tii-SimilarityReportPanelHeader__SettingsButton',
228
+ 'tii-sws-download-btn-mfe',
229
+ 'tii-sws-submission-details-btn',
230
+ ].join(', ');
231
+
232
+ async function waitForModernViewerReady(
233
+ viewerPage: Page,
234
+ viewerUrl: string,
235
+ timeoutMs = 75_000,
236
+ ): Promise<boolean> {
237
+ const deadline = Date.now() + timeoutMs;
238
+ let reloads = 0;
239
+
240
+ while (Date.now() < deadline) {
241
+ if (
242
+ await viewerPage
243
+ .locator(MODERN_VIEWER_READY_SELECTOR)
244
+ .first()
245
+ .isVisible({ timeout: 2500 })
246
+ .catch(() => false)
247
+ ) {
248
+ return true;
249
+ }
250
+
251
+ const bodyText = await viewerPage
252
+ .locator('body')
253
+ .innerText({ timeout: 1500 })
254
+ .catch(() => '');
255
+
256
+ if (bodyText.trim().length > 20) {
257
+ await viewerPage.waitForTimeout(2500).catch(() => {});
258
+ continue;
259
+ }
260
+
261
+ if (reloads < 2) {
262
+ reloads++;
263
+ logger.warn('Report viewer body was empty; reloading direct viewer URL', {
264
+ reloads,
265
+ viewerUrl,
266
+ });
267
+ await viewerPage
268
+ .goto(viewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 })
269
+ .catch(() => {});
270
+ await viewerPage
271
+ .waitForLoadState('networkidle', { timeout: 20000 })
272
+ .catch(() => {});
273
+ await viewerPage.waitForTimeout(5000).catch(() => {});
274
+ continue;
275
+ }
276
+
277
+ await viewerPage.waitForTimeout(2500).catch(() => {});
278
+ }
279
+
280
+ return false;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Main orchestrator
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Run a complete Turnitin automation job.
289
+ *
290
+ * Flow:
291
+ * 1. Launch browser context (headless per config)
292
+ * 2. Restore storageState if available
293
+ * 3. Login
294
+ * 4. Navigate to assignment
295
+ * 5. If mode=quota_check: run quota check and return
296
+ * 6. Detect quota limit before upload
297
+ * 7. If already submitted (resume or live detection): skip to step 8
298
+ * Otherwise, if mode=resubmit: call resubmit, else call upload
299
+ * 8. Wait for similarity
300
+ * 9. Open viewer
301
+ * 10. Apply filters
302
+ * 11. Download PDF
303
+ * 12. Save storageState
304
+ * 13. Close context
305
+ * 14. Return result
306
+ */
307
+ export async function runTurnitinJob(
308
+ input: RunTurnitinJobInput,
309
+ ): Promise<RunTurnitinJobResult> {
310
+ if (input.assignmentTarget.uiVariant === 'legacy_carta') {
311
+ return runLegacyTurnitinJob(input);
312
+ }
313
+
314
+ const {
315
+ account,
316
+ assignmentTarget,
317
+ inputFilePath,
318
+ inputFileName,
319
+ inputFileSize,
320
+ outputDir,
321
+ mode: requestedMode,
322
+ filters,
323
+ storageStatePath,
324
+ resumeAfterStep,
325
+ resumeViewerUrl,
326
+ attemptCount,
327
+ onEvent,
328
+ } = input;
329
+
330
+ const result: RunTurnitinJobResult = {};
331
+ let similarityResult: SimilarityResult | null = null;
332
+ let quotaClassDropAttempted = false;
333
+ let mode = requestedMode;
334
+ let viewerPage: Page | Frame | null = null;
335
+
336
+ // Track whether we should skip the upload step entirely.
337
+ const skipUpload = isStepCompleted(resumeAfterStep, 'submitted');
338
+ const resumeFromViewer = isStepCompleted(resumeAfterStep, 'viewer') && Boolean(resumeViewerUrl);
339
+ const expectedInputFileName = inputFileName || path.basename(inputFilePath);
340
+ const isModernOnePool = assignmentTarget.accountPoolKey === MODERN_ONE_POOL_KEY;
341
+ if (skipUpload) {
342
+ logger.info('Resuming after previous upload — skipping upload/resubmit step', {
343
+ resumeAfterStep,
344
+ });
345
+ }
346
+
347
+ // --- 1. Launch browser context ---
348
+ // NEW-BUG-1 FIX: Use the shared browser from browser-pool.ts instead of
349
+ // launching a new Chromium process for every job. This reduces memory from
350
+ // N × ~200MB to ~200MB + N × ~30MB (one context per job).
351
+ await emit(onEvent, 'info', 'browser', 'Creating browser context');
352
+
353
+ const browser = await getBrowser();
354
+
355
+ // --- 2. Create context (restore storageState if available) ---
356
+ const contextOptions: Record<string, unknown> = {
357
+ userAgent:
358
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
359
+ viewport: { width: 1366, height: 768 },
360
+ acceptDownloads: true,
361
+ extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
362
+ };
363
+
364
+ if (storageStatePath && fs.existsSync(storageStatePath)) {
365
+ contextOptions.storageState = storageStatePath;
366
+ logger.info('Restoring storage state', { path: storageStatePath });
367
+ }
368
+
369
+ const context = await browser.newContext(contextOptions as any);
370
+ let page: Page | null = null;
371
+
372
+ const dropClassAfterQuotaLimit = async (
373
+ quotaMessage: string,
374
+ ): Promise<void> => {
375
+ if (quotaClassDropAttempted || !page) return;
376
+ quotaClassDropAttempted = true;
377
+
378
+ await emit(
379
+ onEvent,
380
+ 'warning',
381
+ 'class_cleanup',
382
+ 'Quota limit detected; dropping class from account',
383
+ {
384
+ classTitle: assignmentTarget.classTitle,
385
+ quotaMessage,
386
+ },
387
+ );
388
+
389
+ const dropResult = await dropClassByTitle(
390
+ page,
391
+ assignmentTarget.classTitle,
392
+ );
393
+
394
+ await emit(
395
+ onEvent,
396
+ dropResult.dropped ? 'info' : 'warning',
397
+ 'class_cleanup',
398
+ dropResult.dropped
399
+ ? 'Quota-limited class dropped or already absent'
400
+ : 'Quota-limited class could not be dropped automatically',
401
+ { ...dropResult },
402
+ );
403
+ };
404
+
405
+ try {
406
+ page = await context.newPage();
407
+
408
+ // --- 3. Login ---
409
+ await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', {
410
+ email: account.email,
411
+ });
412
+ await loginToTurnitin(
413
+ page,
414
+ account.email,
415
+ account.password,
416
+ storageStatePath,
417
+ assignmentTarget.targetUrl,
418
+ );
419
+ result.lastCompletedStep = 'login';
420
+
421
+ let assignmentLaunchUrl = ''; // initialized to avoid undefined crash
422
+
423
+ if (resumeFromViewer && resumeViewerUrl) {
424
+ await emit(
425
+ onEvent,
426
+ 'info',
427
+ 'viewer',
428
+ 'Reopening report viewer from previous attempt',
429
+ { viewerUrl: resumeViewerUrl, resumeAfterStep },
430
+ );
431
+
432
+ const resumedViewerPage = await context.newPage();
433
+ await resumedViewerPage.goto(resumeViewerUrl, {
434
+ waitUntil: 'domcontentloaded',
435
+ timeout: 60000,
436
+ });
437
+ await resumedViewerPage
438
+ .waitForLoadState('networkidle', { timeout: 30000 })
439
+ .catch(() => {});
440
+ const viewerReady = await waitForModernViewerReady(
441
+ resumedViewerPage,
442
+ resumeViewerUrl,
443
+ );
444
+
445
+ if (viewerReady) {
446
+ viewerPage = resumedViewerPage;
447
+ result.viewerUrl = resumedViewerPage.url();
448
+ result.lastCompletedStep = 'viewer';
449
+
450
+ await emit(
451
+ onEvent,
452
+ 'info',
453
+ 'viewer',
454
+ 'Report viewer opened',
455
+ { viewerUrl: result.viewerUrl, resumed: true },
456
+ );
457
+ } else {
458
+ await emit(
459
+ onEvent,
460
+ 'warning',
461
+ 'viewer',
462
+ 'Direct report viewer did not hydrate; reopening through assignment page',
463
+ { viewerUrl: resumeViewerUrl, resumed: true },
464
+ );
465
+ await resumedViewerPage.close().catch(() => {});
466
+ }
467
+ }
468
+
469
+ if (!viewerPage) {
470
+ // --- 4. Navigate to assignment ---
471
+ await emit(
472
+ onEvent,
473
+ 'info',
474
+ 'navigate',
475
+ 'Navigating to assignment',
476
+ {
477
+ classTitle: assignmentTarget.classTitle,
478
+ assignmentTitle: assignmentTarget.assignmentTitle,
479
+ },
480
+ );
481
+
482
+ // Retry loop for EULA-blocked assignment access (up to 3 attempts)
483
+ for (let attempt = 1; attempt <= 3; attempt++) {
484
+ try {
485
+ assignmentLaunchUrl = await navigateToAssignment(
486
+ page,
487
+ assignmentTarget.classTitle,
488
+ assignmentTarget.assignmentTitle,
489
+ );
490
+ result.lastCompletedStep = 'navigate';
491
+
492
+ // --- 5. Quota check mode ---
493
+ if (mode === 'quota_check') {
494
+ await emit(
495
+ onEvent,
496
+ 'info',
497
+ 'quota_check',
498
+ 'Running quota check',
499
+ );
500
+ const quotaResult = await runQuotaCheck(page);
501
+
502
+ if (quotaResult.quotaLimited) {
503
+ result.quotaLimit = {
504
+ limit: quotaResult.limit!,
505
+ message: quotaResult.message!,
506
+ retryText: quotaResult.retryText || undefined,
507
+ };
508
+ await dropClassAfterQuotaLimit(result.quotaLimit.message);
509
+ }
510
+ if (quotaResult.warning) {
511
+ result.quotaWarning = quotaResult.warning;
512
+ }
513
+
514
+ await emit(
515
+ onEvent,
516
+ 'info',
517
+ 'quota_check',
518
+ 'Quota check complete',
519
+ { quotaResult },
520
+ );
521
+
522
+ return result;
523
+ }
524
+
525
+ // --- 6. Detect quota limit before upload ---
526
+ // A quota-limit banner can still be visible after the third successful
527
+ // submission. If this job is resuming after upload/submission, do not
528
+ // abort here: no additional submission is needed, and the worker only
529
+ // has to open/download the existing report.
530
+ const preUploadQuota = skipUpload
531
+ ? null
532
+ : await detectQuotaLimit(page);
533
+ if (preUploadQuota) {
534
+ result.quotaLimit = {
535
+ limit: preUploadQuota.limit,
536
+ message: preUploadQuota.message,
537
+ retryText: preUploadQuota.retryText,
538
+ };
539
+ await dropClassAfterQuotaLimit(preUploadQuota.message);
540
+ throw new SubmissionQuotaLimitError(preUploadQuota.message);
541
+ }
542
+
543
+ // --- 7. Upload / Resubmit / Skip ---
544
+ // Detect live page state to decide what action is needed.
545
+ const submissionState = await detectSubmissionState(page);
546
+
547
+ if (!skipUpload && isModernOnePool && submissionState.hasExistingSubmission) {
548
+ const message =
549
+ 'Modern one-use account already has a submission for this assignment. Marking account as permanently limited and trying another account.';
550
+ result.quotaLimit = {
551
+ limit: 1,
552
+ message,
553
+ };
554
+ await dropClassAfterQuotaLimit(message);
555
+ throw new SubmissionQuotaLimitError(message);
556
+ }
557
+
558
+ if (
559
+ !skipUpload &&
560
+ !viewerPage &&
561
+ (attemptCount || 0) > 1 &&
562
+ submissionState.hasExistingSubmission &&
563
+ typeof inputFileSize === 'number'
564
+ ) {
565
+ await emit(
566
+ onEvent,
567
+ 'info',
568
+ 'recovery_check',
569
+ 'Existing submission found on retry; verifying file details before deciding whether to skip upload',
570
+ {
571
+ inputFileName: expectedInputFileName,
572
+ inputFileSize,
573
+ attemptCount,
574
+ },
575
+ );
576
+
577
+ const candidateViewer = await openReportViewerPage(
578
+ page,
579
+ context,
580
+ submissionState.scope || undefined,
581
+ ).catch(async (error: unknown) => {
582
+ await emit(
583
+ onEvent,
584
+ 'warning',
585
+ 'recovery_check',
586
+ 'Existing submission could not be opened for recovery verification',
587
+ {
588
+ error: error instanceof Error ? error.message : String(error),
589
+ },
590
+ );
591
+ return null;
592
+ });
593
+
594
+ if (candidateViewer) {
595
+ const recoveredDetails = await readSubmissionDetails(candidateViewer);
596
+ if (recoveredDetails) {
597
+ result.submissionDetails = recoveredDetails;
598
+ await emit(
599
+ onEvent,
600
+ 'info',
601
+ 'submission_details',
602
+ 'Submission details captured',
603
+ recoveredDetails as Record<string, unknown>,
604
+ );
605
+ }
606
+
607
+ if (
608
+ submissionDetailsMatchInput(
609
+ recoveredDetails,
610
+ expectedInputFileName,
611
+ inputFileSize,
612
+ )
613
+ ) {
614
+ viewerPage = candidateViewer;
615
+ result.viewerUrl = candidateViewer.url();
616
+ result.lastCompletedStep = 'viewer';
617
+
618
+ await emit(
619
+ onEvent,
620
+ 'info',
621
+ 'viewer',
622
+ 'Existing submission matches current file; resuming from report viewer',
623
+ {
624
+ viewerUrl: result.viewerUrl,
625
+ recovered: true,
626
+ matchBasis: 'fileName+fileSize',
627
+ },
628
+ );
629
+
630
+ break;
631
+ }
632
+
633
+ await emit(
634
+ onEvent,
635
+ 'warning',
636
+ 'recovery_check',
637
+ 'Existing submission does not match current input file; continuing with requested upload flow',
638
+ {
639
+ inputFileName: expectedInputFileName,
640
+ inputFileSize,
641
+ turnitinFileName: recoveredDetails?.fileName || null,
642
+ turnitinFileSize: recoveredDetails?.fileSize || null,
643
+ },
644
+ );
645
+
646
+ const candidateOwner = getScopeOwnerPage(candidateViewer);
647
+ if (candidateOwner !== page) {
648
+ await candidateOwner.close().catch(() => {});
649
+ } else {
650
+ await page.goto(assignmentLaunchUrl, {
651
+ waitUntil: 'domcontentloaded',
652
+ timeout: 60000,
653
+ }).catch(() => {});
654
+ await page.waitForTimeout(1500).catch(() => {});
655
+ }
656
+ }
657
+ }
658
+
659
+ if (viewerPage) {
660
+ break;
661
+ }
662
+
663
+ // ── A: File was already submitted (resume scenario) ──
664
+ // If the caller told us to skip upload, ALWAYS skip — do not
665
+ // re-check the live page because the submission card may not be
666
+ // visible yet (Turnitin is still processing it).
667
+ if (skipUpload) {
668
+ await emit(
669
+ onEvent,
670
+ 'info',
671
+ 'resume',
672
+ 'Resuming after previous upload — skipping upload step',
673
+ { resumeAfterStep, hasExistingSubmission: submissionState.hasExistingSubmission },
674
+ );
675
+ result.lastCompletedStep = 'submitted';
676
+ result.submittedAt = new Date().toISOString(); // approximate
677
+ } else {
678
+ // ── B: Normal upload / resubmit ──
679
+ if (mode === 'upload' && submissionState.hasExistingSubmission) {
680
+ mode = 'resubmit';
681
+ await emit(
682
+ onEvent,
683
+ 'warning',
684
+ 'mode_switch',
685
+ 'Existing submission detected; switching to resubmit mode',
686
+ {
687
+ requestedMode,
688
+ effectiveMode: mode,
689
+ hasResubmitAction: submissionState.hasResubmitAction,
690
+ },
691
+ );
692
+ } else if (
693
+ mode === 'resubmit' &&
694
+ !submissionState.hasExistingSubmission &&
695
+ submissionState.hasUploadForm
696
+ ) {
697
+ mode = 'upload';
698
+ await emit(
699
+ onEvent,
700
+ 'warning',
701
+ 'mode_switch',
702
+ 'No existing submission detected; switching to first submission mode',
703
+ {
704
+ requestedMode,
705
+ effectiveMode: mode,
706
+ },
707
+ );
708
+ }
709
+
710
+ if (mode === 'resubmit') {
711
+ await emit(
712
+ onEvent,
713
+ 'info',
714
+ 'resubmit',
715
+ 'Resubmitting file',
716
+ { filePath: inputFilePath },
717
+ );
718
+ await resubmitFile(page, inputFilePath);
719
+ } else {
720
+ await emit(onEvent, 'info', 'upload', 'Uploading file', {
721
+ filePath: inputFilePath,
722
+ });
723
+ await uploadFile(page, inputFilePath);
724
+ }
725
+
726
+ result.submittedAt = new Date().toISOString();
727
+ result.lastCompletedStep = 'submitted';
728
+ await emit(
729
+ onEvent,
730
+ 'info',
731
+ 'submitted',
732
+ 'File submitted successfully',
733
+ { submittedAt: result.submittedAt },
734
+ );
735
+ }
736
+
737
+ // --- 8. Wait for similarity ---
738
+ await emit(
739
+ onEvent,
740
+ 'info',
741
+ 'similarity',
742
+ 'Waiting for similarity score',
743
+ );
744
+ similarityResult = await waitForSimilarity(
745
+ page,
746
+ assignmentLaunchUrl!,
747
+ {
748
+ timeoutMs: config.similarityTimeoutMs,
749
+ pollMs: config.similarityPollMs,
750
+ refreshAfterMs: config.similarityRefreshAfterMs,
751
+ inputFileName: path.basename(inputFilePath),
752
+ },
753
+ );
754
+
755
+ if (similarityResult.similarityPercent !== null) {
756
+ result.similarityPercent = similarityResult.similarityPercent;
757
+ }
758
+ if (similarityResult.viewerUrl) {
759
+ result.viewerUrl = similarityResult.viewerUrl;
760
+ }
761
+ result.lastCompletedStep = 'similarity';
762
+
763
+ await emit(
764
+ onEvent,
765
+ 'info',
766
+ 'similarity',
767
+ `Similarity: ${result.similarityPercent ?? 'pending'}%`,
768
+ { similarityPercent: result.similarityPercent },
769
+ );
770
+
771
+ break; // Success – exit retry loop
772
+ } catch (error) {
773
+ if (
774
+ error instanceof EulaBlockedError &&
775
+ attempt < 3
776
+ ) {
777
+ await emit(
778
+ onEvent,
779
+ 'warning',
780
+ 'navigate',
781
+ 'Assignment launch returned EULA block; retrying',
782
+ { attempt },
783
+ );
784
+ // Navigate back to class page and retry
785
+ await page.goto(
786
+ `https://www.turnitin.com/s_home.asp?lang=en_us`,
787
+ { waitUntil: 'domcontentloaded', timeout: 60000 },
788
+ );
789
+ continue;
790
+ }
791
+ throw error;
792
+ }
793
+ }
794
+ }
795
+
796
+ if (!viewerPage && !assignmentLaunchUrl) {
797
+ throw new Error('Assignment launch URL was not captured — cannot proceed to similarity wait');
798
+ }
799
+
800
+ // --- 9. Open viewer ---
801
+ if (!viewerPage) {
802
+ await emit(
803
+ onEvent,
804
+ 'info',
805
+ 'viewer',
806
+ 'Opening report viewer',
807
+ );
808
+ if (!similarityResult) {
809
+ throw new Error('Similarity/submission card result was not captured before opening viewer');
810
+ }
811
+ viewerPage = await openReportViewerPage(
812
+ page,
813
+ context,
814
+ similarityResult.scope,
815
+ similarityResult.locator,
816
+ );
817
+ result.viewerUrl = viewerPage.url();
818
+ result.lastCompletedStep = 'viewer';
819
+ await emit(
820
+ onEvent,
821
+ 'info',
822
+ 'viewer',
823
+ 'Report viewer opened',
824
+ { viewerUrl: result.viewerUrl, resumed: false },
825
+ );
826
+ }
827
+
828
+ if (!viewerPage) {
829
+ throw new Error('Report viewer page was not available after open/resume');
830
+ }
831
+
832
+ const submissionDetails = await readSubmissionDetails(viewerPage);
833
+ if (submissionDetails) {
834
+ result.submissionDetails = submissionDetails;
835
+ await emit(
836
+ onEvent,
837
+ 'info',
838
+ 'submission_details',
839
+ 'Submission details captured',
840
+ submissionDetails as Record<string, unknown>,
841
+ );
842
+ }
843
+
844
+ // --- 10. Apply filters ---
845
+ await emit(onEvent, 'info', 'filters', 'Applying filters', {
846
+ filters,
847
+ });
848
+ try {
849
+ await applyFilters(viewerPage, filters);
850
+ } catch (filterError) {
851
+ const filterMessage =
852
+ filterError instanceof Error
853
+ ? filterError.message
854
+ : String(filterError);
855
+
856
+ // When the user has enabled at least one filter, filter application
857
+ // is MANDATORY — do NOT proceed to download with an unfiltered report.
858
+ if (hasActiveFilters(filters)) {
859
+ await emit(
860
+ onEvent,
861
+ 'error',
862
+ 'filters',
863
+ 'Filters are enabled but could not be applied; aborting download to prevent unfiltered report',
864
+ { error: filterMessage, filters },
865
+ );
866
+ throw new Error(
867
+ `Filter application failed with active filters: ${filterMessage}`,
868
+ );
869
+ }
870
+
871
+ // No filters are active — the panel might simply be unavailable;
872
+ // it is safe to continue with the download.
873
+ await emit(
874
+ onEvent,
875
+ 'warning',
876
+ 'filters',
877
+ 'Filter panel was not available (no filters were active); continuing with report download',
878
+ { error: filterMessage },
879
+ );
880
+ }
881
+ result.lastCompletedStep = 'filters';
882
+
883
+ // --- 10b. Validate that all active filters were applied before downloading ---
884
+ if (hasActiveFilters(filters)) {
885
+ await emit(
886
+ onEvent,
887
+ 'info',
888
+ 'filters',
889
+ 'Validating applied filters before download',
890
+ { filters },
891
+ );
892
+ await validateFilters(viewerPage, filters, true /* retryApply */);
893
+ await emit(
894
+ onEvent,
895
+ 'info',
896
+ 'filters',
897
+ 'Filter validation passed',
898
+ { filters },
899
+ );
900
+ }
901
+
902
+ // Read the similarity score from the viewer after filters are applied.
903
+ // The score may have changed due to filter exclusions; give the UI a
904
+ // moment to re-render before reading.
905
+ await emit(
906
+ onEvent,
907
+ 'info',
908
+ 'similarity',
909
+ 'Reading post-filter similarity score from viewer',
910
+ );
911
+
912
+ const viewerSimilarityPercent =
913
+ await readViewerSimilarityPercent(viewerPage);
914
+ if (viewerSimilarityPercent !== null) {
915
+ result.similarityPercent = viewerSimilarityPercent;
916
+ await emit(
917
+ onEvent,
918
+ 'info',
919
+ 'similarity',
920
+ `Viewer similarity (post-filter): ${viewerSimilarityPercent}%`,
921
+ { similarityPercent: viewerSimilarityPercent, filtered: hasActiveFilters(filters) },
922
+ );
923
+ } else {
924
+ await emit(
925
+ onEvent,
926
+ 'warning',
927
+ 'similarity',
928
+ 'Could not read similarity score from viewer; using pre-filter value if available',
929
+ { preFilteSimilarity: result.similarityPercent ?? null },
930
+ );
931
+ }
932
+
933
+ // --- 11. Download PDF ---
934
+ await emit(
935
+ onEvent,
936
+ 'info',
937
+ 'download',
938
+ 'Downloading PDF report',
939
+ );
940
+ fs.mkdirSync(outputDir, { recursive: true });
941
+ const outputPdfPath = path.join(
942
+ outputDir,
943
+ `turnitin_report_${Date.now()}.pdf`,
944
+ );
945
+ const actualPath = await downloadPdf(
946
+ viewerPage,
947
+ context,
948
+ outputPdfPath,
949
+ );
950
+ result.outputPdfPath = actualPath;
951
+ result.lastCompletedStep = 'download';
952
+
953
+ await emit(
954
+ onEvent,
955
+ 'info',
956
+ 'download',
957
+ 'PDF downloaded successfully',
958
+ { outputPdfPath: actualPath },
959
+ );
960
+
961
+ if (isModernOnePool) {
962
+ const message =
963
+ 'Modern one-use account consumed its single allowed submission. Class will be dropped and account will be permanently limited.';
964
+ let dropClassResult: NonNullable<RunTurnitinJobResult['permanentLimit']>['dropClassResult'] | undefined;
965
+ if (page) {
966
+ await emit(onEvent, 'warning', 'class_cleanup', 'Modern one-use submission completed; dropping class from account', {
967
+ classTitle: assignmentTarget.classTitle,
968
+ });
969
+ dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({
970
+ attempted: true,
971
+ dropped: false,
972
+ reason: dropError instanceof Error ? dropError.message : String(dropError),
973
+ }));
974
+ await emit(
975
+ onEvent,
976
+ dropClassResult.dropped ? 'info' : 'warning',
977
+ 'class_cleanup',
978
+ dropClassResult.dropped
979
+ ? 'Modern one-use class dropped or already absent'
980
+ : 'Modern one-use class could not be dropped automatically',
981
+ { ...dropClassResult },
982
+ );
983
+ }
984
+ result.permanentLimit = {
985
+ message,
986
+ submissionCount: 1,
987
+ dropClassResult,
988
+ };
989
+ }
990
+
991
+ // --- 12. Save storageState ---
992
+ if (storageStatePath) {
993
+ await context
994
+ .storageState({ path: storageStatePath })
995
+ .catch(() => {});
996
+ logger.info('Storage state saved after job', {
997
+ path: storageStatePath,
998
+ });
999
+ }
1000
+ } catch (error) {
1001
+ const message =
1002
+ error instanceof Error ? error.message : String(error);
1003
+ const publicMessage = compactEngineErrorMessage(message);
1004
+ const isQuotaLimit =
1005
+ error instanceof Error &&
1006
+ (error.name === 'SubmissionQuotaLimitError' ||
1007
+ /reached your limit|submission quota limit/i.test(message));
1008
+ if (isQuotaLimit && page) {
1009
+ await dropClassAfterQuotaLimit(message).catch((dropError: unknown) => {
1010
+ logger.warn('Failed to drop class after quota limit', {
1011
+ classTitle: assignmentTarget.classTitle,
1012
+ error:
1013
+ dropError instanceof Error
1014
+ ? dropError.message
1015
+ : String(dropError),
1016
+ });
1017
+ });
1018
+ }
1019
+ await emit(onEvent, 'error', 'error', publicMessage, {
1020
+ errorName:
1021
+ error instanceof Error ? error.name : 'UnknownError',
1022
+ });
1023
+ if (error && typeof error === 'object') {
1024
+ (error as any).lastCompletedStep = result.lastCompletedStep;
1025
+ (error as any).viewerUrl = result.viewerUrl;
1026
+ (error as any).similarityPercent = result.similarityPercent;
1027
+ }
1028
+ throw error;
1029
+ } finally {
1030
+ // --- 13. Close context (NOT the browser — it's shared across jobs) ---
1031
+ await context.close().catch(() => {});
1032
+ }
1033
+
1034
+ // --- 14. Return result ---
1035
+ return result;
1036
+ }
src/index.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config, validateConfig } from './config';
2
+ import { logger, setWorkerId } from './utils/logger';
3
+ import { createApp } from './server/app';
4
+ import { startWorkerManager, stopWorkerManager } from './worker/manager';
5
+ import { closeBrowser } from './worker/browser-pool';
6
+ import { runQuotaCheckCron } from './cron/quota-check';
7
+ import { runCleanupReportsCron } from './cron/cleanup-reports';
8
+ import { runStaleRecoveryCron } from './cron/stale-recovery';
9
+ import * as cron from 'node-cron';
10
+
11
+ async function main(): Promise<void> {
12
+ // Validate environment
13
+ try {
14
+ validateConfig();
15
+ } catch (err) {
16
+ console.error('Configuration error:', err instanceof Error ? err.message : err);
17
+ process.exit(1);
18
+ }
19
+
20
+ setWorkerId(config.workerId);
21
+ logger.info('Turnitin Worker starting', {
22
+ workerId: config.workerId,
23
+ maxWorkers: config.maxWorkers,
24
+ port: config.port,
25
+ enableWorker: config.enableWorker,
26
+ enableCron: config.enableCron,
27
+ });
28
+
29
+ // Start Express server
30
+ const app = createApp();
31
+ const server = app.listen(config.port, () => {
32
+ logger.info(`HTTP server listening on port ${config.port}`);
33
+ });
34
+
35
+ if (config.enableWorker) {
36
+ // Start worker manager (polls for jobs)
37
+ startWorkerManager().catch((err) => {
38
+ logger.error('Worker manager fatal error', {
39
+ error: err instanceof Error ? err.message : String(err),
40
+ });
41
+ });
42
+ } else {
43
+ logger.info('Worker manager disabled by ENABLE_WORKER=false');
44
+ }
45
+
46
+ if (config.enableCron) {
47
+ // Schedule cron jobs
48
+ // Quota check: every N minutes
49
+ const quotaCronExpr = `*/${config.quotaCheckInterval} * * * *`;
50
+ cron.schedule(quotaCronExpr, () => {
51
+ runQuotaCheckCron().catch((err) => {
52
+ logger.error('Quota check cron error', {
53
+ error: err instanceof Error ? err.message : String(err),
54
+ });
55
+ });
56
+ });
57
+ logger.info(`Quota check cron scheduled: ${quotaCronExpr}`);
58
+
59
+ // Report cleanup: every N minutes
60
+ const cleanupCronExpr = `*/${config.cleanupInterval} * * * *`;
61
+ cron.schedule(cleanupCronExpr, () => {
62
+ runCleanupReportsCron().catch((err) => {
63
+ logger.error('Report cleanup cron error', {
64
+ error: err instanceof Error ? err.message : String(err),
65
+ });
66
+ });
67
+ });
68
+ logger.info(`Report cleanup cron scheduled: ${cleanupCronExpr}`);
69
+
70
+ // Stale recovery: every N minutes
71
+ const staleCronExpr = `*/${config.staleRecoveryInterval} * * * *`;
72
+ cron.schedule(staleCronExpr, () => {
73
+ runStaleRecoveryCron().catch((err) => {
74
+ logger.error('Stale recovery cron error', {
75
+ error: err instanceof Error ? err.message : String(err),
76
+ });
77
+ });
78
+ });
79
+ logger.info(`Stale recovery cron scheduled: ${staleCronExpr}`);
80
+ } else {
81
+ logger.info('Cron jobs disabled by ENABLE_CRON=false');
82
+ }
83
+
84
+ logger.info('All systems started successfully');
85
+
86
+ // Graceful shutdown
87
+ const shutdown = async (signal: string) => {
88
+ logger.info(`Received ${signal}, shutting down gracefully...`);
89
+
90
+ // Stop accepting new jobs
91
+ stopWorkerManager();
92
+
93
+ // Close HTTP server
94
+ server.close(() => {
95
+ logger.info('HTTP server closed');
96
+ });
97
+
98
+ // Wait for active workers to finish (max 60 seconds)
99
+ const maxWait = 60000;
100
+ const start = Date.now();
101
+ const { getActiveWorkerCount } = await import('./worker/manager');
102
+ while (getActiveWorkerCount() > 0 && Date.now() - start < maxWait) {
103
+ logger.info(`Waiting for ${getActiveWorkerCount()} active workers to finish...`);
104
+ await new Promise((r) => setTimeout(r, 2000));
105
+ }
106
+
107
+ // Close browser
108
+ await closeBrowser();
109
+
110
+ logger.info('Shutdown complete');
111
+ process.exit(0);
112
+ };
113
+
114
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
115
+ process.on('SIGINT', () => shutdown('SIGINT'));
116
+
117
+ // Handle uncaught errors
118
+ process.on('uncaughtException', (err) => {
119
+ logger.error('Uncaught exception', {
120
+ error: err.message,
121
+ stack: err.stack,
122
+ });
123
+ // Don't exit on uncaught exceptions in worker service
124
+ // The worker loop has its own error handling
125
+ });
126
+
127
+ process.on('unhandledRejection', (reason) => {
128
+ logger.error('Unhandled promise rejection', {
129
+ reason: reason instanceof Error ? reason.message : String(reason),
130
+ });
131
+ });
132
+ }
133
+
134
+ main().catch((err) => {
135
+ console.error('Fatal startup error:', err);
136
+ process.exit(1);
137
+ });
src/server/app.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express, { Express, Request, Response, NextFunction } from 'express';
2
+ import { config } from '../config';
3
+ import { logger } from '../utils/logger';
4
+ import healthRouter from './routes/health';
5
+ import submitRouter from './routes/submit';
6
+ import internalRouter from './routes/internal';
7
+ import reportsRouter from './routes/reports';
8
+ import accountsRouter from './routes/accounts';
9
+
10
+ /**
11
+ * Create and configure the Express application with all routes and middleware.
12
+ */
13
+ export function createApp(): Express {
14
+ const app = express();
15
+
16
+ app.disable('x-powered-by');
17
+
18
+ app.use((req: Request, res: Response, next: NextFunction) => {
19
+ const origin = req.headers.origin;
20
+ const allowOrigin =
21
+ origin &&
22
+ (config.allowedOrigins.length === 0 || config.allowedOrigins.includes(origin));
23
+
24
+ if (allowOrigin) {
25
+ res.setHeader('Access-Control-Allow-Origin', origin);
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');
33
+
34
+ if (req.method === 'OPTIONS') {
35
+ res.status(204).end();
36
+ return;
37
+ }
38
+
39
+ next();
40
+ });
41
+
42
+ // --- Body parsers ---
43
+ app.use(express.json({ limit: '1mb' }));
44
+
45
+ // --- Routes ---
46
+ app.use(healthRouter);
47
+ app.use(submitRouter);
48
+ app.use(reportsRouter);
49
+ app.use(accountsRouter);
50
+ app.use(internalRouter);
51
+
52
+ app.use((req: Request, res: Response) => {
53
+ res.status(404).json({ error: 'Not found' });
54
+ });
55
+
56
+ // --- Global error handler ---
57
+ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
58
+ logger.error('Unhandled server error', {
59
+ error: err.message,
60
+ stack: err.stack,
61
+ });
62
+
63
+ res.status(500).json({ error: 'Internal server error' });
64
+ });
65
+
66
+ return app;
67
+ }
src/server/middleware/admin-secret.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { config } from '../../config';
3
+ import { logger } from '../../utils/logger';
4
+
5
+ /**
6
+ * Express middleware that gates internal endpoints behind a shared secret.
7
+ * Reads the X-Admin-Secret header and compares against config.internalSecret.
8
+ * Returns 403 if the secret is missing or incorrect.
9
+ */
10
+ export function requireAdminSecret(
11
+ req: Request,
12
+ res: Response,
13
+ next: NextFunction,
14
+ ): void {
15
+ const secret = req.headers['x-admin-secret'];
16
+
17
+ if (!config.internalSecret) {
18
+ logger.error('INTERNAL_SECRET is not configured — rejecting admin request');
19
+ res.status(403).json({ error: 'Admin endpoints are not configured' });
20
+ return;
21
+ }
22
+
23
+ if (!secret || secret !== config.internalSecret) {
24
+ logger.warn('Admin secret mismatch', {
25
+ ip: req.ip,
26
+ path: req.path,
27
+ });
28
+ res.status(403).json({ error: 'Forbidden: invalid admin secret' });
29
+ return;
30
+ }
31
+
32
+ next();
33
+ }
src/server/middleware/auth.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { supabase } from '../../db/client';
3
+ import { logger } from '../../utils/logger';
4
+
5
+ /** Augmented Express Request with authenticated user fields */
6
+ export interface AuthenticatedRequest extends Request {
7
+ userId: string;
8
+ userEmail: string;
9
+ }
10
+
11
+ /**
12
+ * Express middleware that verifies a Supabase JWT from the Authorization header.
13
+ * On success, sets req.userId and req.userEmail for downstream handlers.
14
+ * Returns 401 if the token is missing or invalid.
15
+ */
16
+ export async function authenticateUser(
17
+ req: Request,
18
+ res: Response,
19
+ next: NextFunction,
20
+ ): Promise<void> {
21
+ const authHeader = req.headers.authorization;
22
+
23
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
24
+ res.status(401).json({ error: 'Missing or malformed Authorization header' });
25
+ return;
26
+ }
27
+
28
+ const token = authHeader.slice(7); // Strip "Bearer "
29
+
30
+ try {
31
+ const { data, error } = await supabase.auth.getUser(token);
32
+
33
+ if (error || !data.user) {
34
+ logger.warn('Auth verification failed', { error: error?.message });
35
+ res.status(401).json({ error: 'Invalid or expired token' });
36
+ return;
37
+ }
38
+
39
+ // Attach user info to the request
40
+ (req as AuthenticatedRequest).userId = data.user.id;
41
+ (req as AuthenticatedRequest).userEmail = data.user.email || '';
42
+
43
+ next();
44
+ } catch (err) {
45
+ logger.error('Auth middleware unexpected error', {
46
+ error: err instanceof Error ? err.message : String(err),
47
+ });
48
+ res.status(401).json({ error: 'Authentication failed' });
49
+ }
50
+ }
src/server/routes/accounts.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, Response } from 'express';
2
+ import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
3
+ import { supabase } from '../../db/client';
4
+ import { logger } from '../../utils/logger';
5
+
6
+ const router = Router();
7
+
8
+ type AccountRow = {
9
+ id: string;
10
+ turnitin_status: string | null;
11
+ turnitin_quota_limit: number | null;
12
+ turnitin_quota_remaining: number | null;
13
+ turnitin_quota_message: string | null;
14
+ turnitin_next_retry_at: string | null;
15
+ turnitin_last_checked_at: string | null;
16
+ turnitin_last_error: string | null;
17
+ turnitin_pool_key: string | null;
18
+ created_at: string;
19
+ };
20
+
21
+ async function requireAdmin(userId: string): Promise<boolean> {
22
+ const { data, error } = await supabase
23
+ .from('user_profiles')
24
+ .select('role')
25
+ .eq('id', userId)
26
+ .single();
27
+
28
+ if (error) {
29
+ logger.warn('Failed to verify admin profile for accounts quota', {
30
+ userId,
31
+ error: error.message,
32
+ });
33
+ return false;
34
+ }
35
+
36
+ return data?.role === 'admin';
37
+ }
38
+
39
+ /**
40
+ * GET /api/accounts/quota
41
+ *
42
+ * Admin-only quota view for Turnitin account cycling. The browser should not
43
+ * read generated_identities directly because storage/account RLS may hide rows.
44
+ * This endpoint returns sanitized account IDs and quota state only.
45
+ */
46
+ router.get(
47
+ '/api/accounts/quota',
48
+ authenticateUser,
49
+ async (req, res: Response): Promise<void> => {
50
+ const authReq = req as AuthenticatedRequest;
51
+
52
+ try {
53
+ if (!(await requireAdmin(authReq.userId))) {
54
+ res.status(403).json({ error: 'Admin access required' });
55
+ return;
56
+ }
57
+
58
+ let { data: accounts, error: accountsError } = await supabase
59
+ .from('generated_identities')
60
+ .select(
61
+ 'id, turnitin_status, turnitin_quota_limit, turnitin_quota_remaining, turnitin_quota_message, turnitin_next_retry_at, turnitin_last_checked_at, turnitin_last_error, turnitin_pool_key, created_at',
62
+ )
63
+ .order('created_at', { ascending: false });
64
+
65
+ if (
66
+ accountsError &&
67
+ /turnitin_pool_key|column/i.test(accountsError.message)
68
+ ) {
69
+ const fallback = await supabase
70
+ .from('generated_identities')
71
+ .select(
72
+ 'id, turnitin_status, turnitin_quota_limit, turnitin_quota_remaining, turnitin_quota_message, turnitin_next_retry_at, turnitin_last_checked_at, turnitin_last_error, created_at',
73
+ )
74
+ .order('created_at', { ascending: false });
75
+ accounts = fallback.data as any;
76
+ accountsError = fallback.error;
77
+ }
78
+
79
+ if (accountsError) throw accountsError;
80
+
81
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
82
+ const { data: submissions, error: submissionsError } = await supabase
83
+ .from('turnitin_submissions')
84
+ .select('identity_id, submitted_at')
85
+ .gte('submitted_at', since);
86
+
87
+ if (submissionsError) throw submissionsError;
88
+
89
+ const usedByIdentity = new Map<string, number>();
90
+ for (const submission of submissions || []) {
91
+ const identityId = submission.identity_id as string | null;
92
+ if (!identityId) continue;
93
+ usedByIdentity.set(identityId, (usedByIdentity.get(identityId) || 0) + 1);
94
+ }
95
+
96
+ const sanitized = ((accounts || []) as AccountRow[]).map((account) => {
97
+ const quotaLimit = account.turnitin_pool_key === 'modern_one'
98
+ ? 1
99
+ : account.turnitin_pool_key === 'legacy_carta'
100
+ ? 4
101
+ : account.turnitin_quota_limit || 3;
102
+ const usedLast24h = usedByIdentity.get(account.id) || 0;
103
+ const hardLimited = ['quota_limited', 'cooling_down'].includes(
104
+ account.turnitin_status || '',
105
+ );
106
+ const quotaRemaining =
107
+ typeof account.turnitin_quota_remaining === 'number'
108
+ ? account.turnitin_quota_remaining
109
+ : hardLimited
110
+ ? 0
111
+ : Math.max(0, quotaLimit - usedLast24h);
112
+
113
+ return {
114
+ id: account.id,
115
+ account_label: `Account #${account.id.replace(/-/g, '').slice(0, 8)}`,
116
+ turnitin_pool_key: account.turnitin_pool_key || 'modern_lti',
117
+ turnitin_status: account.turnitin_status || 'available',
118
+ turnitin_quota_limit: quotaLimit,
119
+ turnitin_quota_remaining: Math.max(0, Math.min(quotaLimit, quotaRemaining)),
120
+ turnitin_quota_message: account.turnitin_quota_message,
121
+ turnitin_next_retry_at: account.turnitin_next_retry_at,
122
+ turnitin_last_checked_at: account.turnitin_last_checked_at,
123
+ turnitin_last_error: account.turnitin_last_error,
124
+ used_last_24h: usedLast24h,
125
+ };
126
+ });
127
+
128
+ res.json({ accounts: sanitized });
129
+ } catch (err: unknown) {
130
+ const message = err instanceof Error ? err.message : String(err);
131
+ logger.error('Failed to load Turnitin account quotas', {
132
+ userId: authReq.userId,
133
+ error: message,
134
+ });
135
+ res.status(500).json({ error: 'Failed to load account quotas' });
136
+ }
137
+ },
138
+ );
139
+
140
+ export default router;
src/server/routes/health.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, Request, Response } from 'express';
2
+ import { config } from '../../config';
3
+ import { getActiveWorkerCount } from '../../worker/manager';
4
+
5
+ const router = Router();
6
+
7
+ const startTime = Date.now();
8
+
9
+ /**
10
+ * GET /
11
+ * Root endpoint returning status indicator (useful for HF Space pings).
12
+ */
13
+ router.get('/', (_req: Request, res: Response) => {
14
+ res.json({
15
+ status: 'operational',
16
+ workerId: config.workerId,
17
+ timestamp: new Date().toISOString(),
18
+ });
19
+ });
20
+
21
+ /**
22
+ * GET /health
23
+ * Basic health check endpoint exposing worker identity and uptime.
24
+ */
25
+ router.get('/health', (_req: Request, res: Response) => {
26
+ res.json({
27
+ ok: true,
28
+ workerId: config.workerId,
29
+ maxWorkers: config.maxWorkers,
30
+ activeWorkers: getActiveWorkerCount(),
31
+ enableWorker: config.enableWorker,
32
+ enableCron: config.enableCron,
33
+ allowedOriginCount: config.allowedOrigins.length,
34
+ uptime: Math.floor((Date.now() - startTime) / 1000),
35
+ });
36
+ });
37
+
38
+ export default router;
src/server/routes/internal.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, Request, Response } from 'express';
2
+ import { config } from '../../config';
3
+ import {
4
+ claimSpecificAccountForManualQuota,
5
+ getTurnitinAccountById,
6
+ } from '../../db/accounts';
7
+ import { requireAdminSecret } from '../middleware/admin-secret';
8
+ import { getJobById } from '../../db/jobs';
9
+ import { runQuotaCheckForAccount } from '../../cron/quota-check';
10
+ import { logger } from '../../utils/logger';
11
+
12
+ const router = Router();
13
+ const MAX_MANUAL_QUOTA_CHECKS_PER_WORKER = 1;
14
+ let activeManualQuotaChecks = 0;
15
+
16
+ // Only /internal routes require the admin secret. Keep this scoped so unknown
17
+ // public paths return 404 instead of noisy "admin secret mismatch" warnings.
18
+ router.use('/internal', requireAdminSecret);
19
+
20
+ /**
21
+ * POST /internal/jobs/:id/run
22
+ * Manually trigger a job run. Stub implementation — validates the job exists
23
+ * and returns 202 Accepted. Actual worker invocation is handled elsewhere.
24
+ */
25
+ router.post('/internal/jobs/:id/run', async (req: Request, res: Response): Promise<void> => {
26
+ const id = String(req.params.id);
27
+
28
+ try {
29
+ const job = await getJobById(id);
30
+ if (!job) {
31
+ res.status(404).json({ error: 'Job not found' });
32
+ return;
33
+ }
34
+
35
+ logger.info('Manual job run requested', { jobId: id });
36
+
37
+ // TODO: Integrate with worker dispatch
38
+ res.status(202).json({ message: 'Job run accepted', jobId: id });
39
+ } catch (err) {
40
+ logger.error('Internal job run error', {
41
+ jobId: id,
42
+ error: err instanceof Error ? err.message : String(err),
43
+ });
44
+ res.status(500).json({ error: 'Internal server error' });
45
+ }
46
+ });
47
+
48
+ /**
49
+ * POST /internal/accounts/:id/quota-check
50
+ * Manually trigger a quota check for a specific account.
51
+ * The check is started in the background after the account is leased, so the
52
+ * dashboard request is not held open for a full browser session.
53
+ */
54
+ router.post(
55
+ '/internal/accounts/:id/quota-check',
56
+ async (req: Request, res: Response): Promise<void> => {
57
+ const id = String(req.params.id);
58
+ let handedOffToBackground = false;
59
+
60
+ try {
61
+ logger.info('Manual quota check requested', { accountId: id });
62
+
63
+ if (activeManualQuotaChecks >= MAX_MANUAL_QUOTA_CHECKS_PER_WORKER) {
64
+ res.status(429).json({
65
+ error: 'This worker is already running a manual quota check. Try another worker or wait briefly.',
66
+ });
67
+ return;
68
+ }
69
+
70
+ activeManualQuotaChecks += 1;
71
+
72
+ const existingAccount = await getTurnitinAccountById(id);
73
+ if (!existingAccount) {
74
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
75
+ res.status(404).json({ error: 'Turnitin account not found' });
76
+ return;
77
+ }
78
+
79
+ if (existingAccount.turnitin_status === 'disabled') {
80
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
81
+ res.status(409).json({ error: 'This account is disabled and cannot be checked.' });
82
+ return;
83
+ }
84
+
85
+ if (existingAccount.turnitin_status === 'login_failed') {
86
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
87
+ res.status(409).json({
88
+ error: 'This account is marked login_failed. Fix the credential before checking quota.',
89
+ });
90
+ return;
91
+ }
92
+
93
+ if (existingAccount.turnitin_pool_key === 'legacy_carta' || existingAccount.turnitin_pool_key === 'modern_one') {
94
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
95
+ res.status(409).json({
96
+ error:
97
+ 'Manual quota check supports reusable modern Turnitin accounts only. Legacy and modern_one pools are finalized after submission.',
98
+ });
99
+ return;
100
+ }
101
+
102
+ const leaseOwner = `${config.workerId}:manual-quota-check`;
103
+ const claimedAccount = await claimSpecificAccountForManualQuota(id, leaseOwner);
104
+ if (!claimedAccount) {
105
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
106
+ res.status(409).json({
107
+ error: 'This account is currently leased by another worker or is not ready for checking.',
108
+ });
109
+ return;
110
+ }
111
+
112
+ handedOffToBackground = true;
113
+ void runQuotaCheckForAccount(claimedAccount, {
114
+ source: 'manual',
115
+ fallbackStatus: existingAccount.turnitin_status || 'available',
116
+ })
117
+ .catch((err: unknown) => {
118
+ logger.error('Manual quota check background task failed', {
119
+ accountId: id,
120
+ error: err instanceof Error ? err.message : String(err),
121
+ });
122
+ })
123
+ .finally(() => {
124
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
125
+ });
126
+
127
+ res.status(202).json({
128
+ message: 'Quota check started',
129
+ accountId: id,
130
+ workerId: config.workerId,
131
+ });
132
+ } catch (err: unknown) {
133
+ logger.error('Manual quota check request failed', {
134
+ accountId: id,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ });
137
+ if (!handedOffToBackground) {
138
+ activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
139
+ }
140
+ res.status(500).json({ error: 'Failed to start quota check' });
141
+ }
142
+ },
143
+ );
144
+
145
+ export default router;
src/server/routes/reports.ts ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, Response } from 'express';
2
+ import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
3
+ import { getJobById } from '../../db/jobs';
4
+ import { createSignedUrl } from '../../db/storage';
5
+ import { config } from '../../config';
6
+ import { logger } from '../../utils/logger';
7
+
8
+ const router = Router();
9
+
10
+ function buildReportDownloadName(inputFileName: string): string {
11
+ const base = String(inputFileName || 'report')
12
+ .replace(/\.[^.]+$/, '')
13
+ .replace(/[\\/:*?"<>|]+/g, ' ')
14
+ .replace(/\s+/g, ' ')
15
+ .trim()
16
+ .slice(0, 140) || 'report';
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
+ *
33
+ * Creates a short-lived signed URL for a completed report PDF. The browser
34
+ * should not sign private storage objects directly because storage RLS can hide
35
+ * private files as "not found"; this endpoint verifies job ownership first and
36
+ * then signs with the backend service role.
37
+ */
38
+ router.get(
39
+ '/api/jobs/:jobId/report-url',
40
+ authenticateUser,
41
+ async (req, res: Response): Promise<void> => {
42
+ const authReq = req as AuthenticatedRequest;
43
+ const jobId = String(authReq.params.jobId || '');
44
+
45
+ try {
46
+ const job = await getJobById(jobId);
47
+
48
+ if (!job) {
49
+ res.status(404).json({ error: 'Job not found' });
50
+ return;
51
+ }
52
+
53
+ if (job.user_id !== authReq.userId) {
54
+ res.status(403).json({ error: 'Forbidden' });
55
+ return;
56
+ }
57
+
58
+ if (!job.output_pdf_path) {
59
+ res.status(404).json({ error: 'Report PDF is not available yet' });
60
+ return;
61
+ }
62
+
63
+ if (
64
+ job.output_pdf_expires_at &&
65
+ new Date(job.output_pdf_expires_at).getTime() < Date.now()
66
+ ) {
67
+ res.status(410).json({ error: 'Report PDF has expired' });
68
+ return;
69
+ }
70
+
71
+ const signedUrl = await createSignedUrl(
72
+ config.reportBucket,
73
+ job.output_pdf_path,
74
+ 300,
75
+ buildReportDownloadName(job.input_file_name),
76
+ );
77
+
78
+ res.json({
79
+ signedUrl,
80
+ fileName: buildReportDownloadName(job.input_file_name),
81
+ expiresIn: 300,
82
+ expiresAt: new Date(Date.now() + 300 * 1000).toISOString(),
83
+ });
84
+ } catch (err: unknown) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ logger.error('Failed to create report signed URL', {
87
+ jobId,
88
+ userId: authReq.userId,
89
+ error: message,
90
+ });
91
+ res.status(500).json({ error: 'Failed to create report download URL' });
92
+ }
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 ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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
+ 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
+
15
+ /** Multer configured for in-memory storage (files go to Supabase, not local disk) */
16
+ const upload = multer({
17
+ storage: multer.memoryStorage(),
18
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
19
+ });
20
+
21
+ /** Allowed file extensions for Turnitin submissions */
22
+ const ALLOWED_EXTENSIONS = new Set([
23
+ '.docx',
24
+ '.xlsx',
25
+ '.pptx',
26
+ '.ps',
27
+ '.pdf',
28
+ '.html',
29
+ '.rtf',
30
+ '.odt',
31
+ '.hwp',
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,
39
+ excludeQuotes: false,
40
+ excludeCitations: false,
41
+ excludeSmallMatches: true,
42
+ smallMatchMode: 'words',
43
+ smallMatchThreshold: 8,
44
+ };
45
+
46
+ function getFileExtension(filename: string): string {
47
+ const lastDot = filename.lastIndexOf('.');
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,
79
+ * creates a job (deducting a ticket), and returns the job ID.
80
+ */
81
+ router.post(
82
+ '/api/submit',
83
+ authenticateUser,
84
+ upload.single('file'),
85
+ async (req, res: Response): Promise<void> => {
86
+ const authReq = req as AuthenticatedRequest;
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({
99
+ error: 'Missing required fields: assignment_target_id, mode',
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (!ALLOWED_MODES.has(String(mode))) {
105
+ res.status(400).json({
106
+ error: 'Invalid mode. Allowed: upload, resubmit',
107
+ });
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 {
124
+ const parsedFilters =
125
+ typeof filtersRaw === 'string'
126
+ ? JSON.parse(filtersRaw)
127
+ : filtersRaw && typeof filtersRaw === 'object'
128
+ ? filtersRaw
129
+ : {};
130
+ filters = { ...DEFAULT_FILTERS, ...parsedFilters };
131
+ } catch {
132
+ res.status(400).json({ error: 'Invalid filters JSON' });
133
+ return;
134
+ }
135
+
136
+ // 2. Validate file exists
137
+ if (!authReq.file) {
138
+ res.status(400).json({ error: 'File is required' });
139
+ return;
140
+ }
141
+
142
+ // 3. Validate file type
143
+ const ext = getFileExtension(authReq.file.originalname);
144
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
145
+ res.status(400).json({
146
+ error: `Unsupported file type: ${ext}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`,
147
+ });
148
+ return;
149
+ }
150
+
151
+ // 4. Validate filters. Legacy Turnitin supports small-match words,
152
+ // percent, or off; modern Turnitin uses word threshold only.
153
+ const smallMatchMode =
154
+ filters.smallMatchMode === 'percent' ||
155
+ filters.smallMatchMode === 'off' ||
156
+ filters.smallMatchMode === 'words'
157
+ ? filters.smallMatchMode
158
+ : 'words';
159
+ filters.smallMatchMode = smallMatchMode;
160
+
161
+ if (filters.excludeSmallMatches === true && smallMatchMode !== 'off') {
162
+ let threshold = Number(filters.smallMatchThreshold) || 8;
163
+ const maxThreshold = smallMatchMode === 'percent' ? 100 : 40;
164
+ threshold = Math.max(1, Math.min(maxThreshold, threshold));
165
+ filters.smallMatchThreshold = threshold;
166
+ } else {
167
+ filters.excludeSmallMatches = false;
168
+ filters.smallMatchMode = 'off';
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,
226
+ filters,
227
+ inputFileName: authReq.file.originalname,
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
+
254
+ // Handle specific error cases from the RPC
255
+ if (message.includes('insufficient') || message.includes('ticket')) {
256
+ res.status(402).json({ error: 'Insufficient ticket balance' });
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,
268
+ });
269
+
270
+ res.status(500).json({ error: 'Internal server error' });
271
+ }
272
+ },
273
+ );
274
+
275
+ export default router;
src/utils/logger.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Structured logger with worker context */
2
+
3
+ type LogLevel = 'info' | 'warn' | 'error' | 'debug';
4
+
5
+ interface LogEntry {
6
+ level: LogLevel;
7
+ ts: string;
8
+ worker?: string;
9
+ job?: string;
10
+ account?: string;
11
+ step?: string;
12
+ msg: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ let globalWorkerId = 'unknown';
17
+
18
+ export function setWorkerId(id: string): void {
19
+ globalWorkerId = id;
20
+ }
21
+
22
+ function write(level: LogLevel, msg: string, extra?: Record<string, unknown>): void {
23
+ const entry: LogEntry = {
24
+ level,
25
+ ts: new Date().toISOString(),
26
+ worker: globalWorkerId,
27
+ msg,
28
+ ...extra,
29
+ };
30
+ const line = JSON.stringify(entry);
31
+ if (level === 'error') {
32
+ process.stderr.write(line + '\n');
33
+ } else {
34
+ process.stdout.write(line + '\n');
35
+ }
36
+ }
37
+
38
+ export const logger = {
39
+ info: (msg: string, extra?: Record<string, unknown>) => write('info', msg, extra),
40
+ warn: (msg: string, extra?: Record<string, unknown>) => write('warn', msg, extra),
41
+ error: (msg: string, extra?: Record<string, unknown>) => write('error', msg, extra),
42
+ debug: (msg: string, extra?: Record<string, unknown>) => write('debug', msg, extra),
43
+
44
+ /** Create a child logger with preset context fields */
45
+ child(context: Record<string, unknown>) {
46
+ return {
47
+ info: (msg: string, extra?: Record<string, unknown>) =>
48
+ write('info', msg, { ...context, ...extra }),
49
+ warn: (msg: string, extra?: Record<string, unknown>) =>
50
+ write('warn', msg, { ...context, ...extra }),
51
+ error: (msg: string, extra?: Record<string, unknown>) =>
52
+ write('error', msg, { ...context, ...extra }),
53
+ debug: (msg: string, extra?: Record<string, unknown>) =>
54
+ write('debug', msg, { ...context, ...extra }),
55
+ };
56
+ },
57
+ };
src/utils/retry.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { logger } from './logger';
2
+
3
+ interface RetryOptions {
4
+ maxAttempts: number;
5
+ baseDelayMs?: number;
6
+ maxDelayMs?: number;
7
+ retryIf?: (error: unknown) => boolean;
8
+ label?: string;
9
+ }
10
+
11
+ /**
12
+ * Retry a function with exponential backoff.
13
+ * Returns the result on success or throws after all attempts fail.
14
+ */
15
+ export async function retry<T>(
16
+ fn: () => Promise<T>,
17
+ options: RetryOptions
18
+ ): Promise<T> {
19
+ const {
20
+ maxAttempts,
21
+ baseDelayMs = 1000,
22
+ maxDelayMs = 30000,
23
+ retryIf,
24
+ label = 'operation',
25
+ } = options;
26
+
27
+ let lastError: unknown;
28
+
29
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
30
+ try {
31
+ return await fn();
32
+ } catch (error) {
33
+ lastError = error;
34
+
35
+ if (retryIf && !retryIf(error)) {
36
+ throw error;
37
+ }
38
+
39
+ if (attempt === maxAttempts) {
40
+ break;
41
+ }
42
+
43
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
44
+ const jitter = delay * (0.5 + Math.random() * 0.5);
45
+
46
+ logger.warn(`${label} attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(jitter)}ms`, {
47
+ error: error instanceof Error ? error.message : String(error),
48
+ attempt,
49
+ maxAttempts,
50
+ });
51
+
52
+ await sleep(jitter);
53
+ }
54
+ }
55
+
56
+ throw lastError;
57
+ }
58
+
59
+ /** Promise-based sleep */
60
+ export function sleep(ms: number): Promise<void> {
61
+ return new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
src/worker/browser-pool.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { chromium, Browser, BrowserContext } from 'playwright';
2
+ import { config } from '../config';
3
+ import { logger } from '../utils/logger';
4
+
5
+ let browser: Browser | null = null;
6
+ let browserLaunchPromise: Promise<Browser> | null = null;
7
+
8
+ /**
9
+ * Get or launch the shared browser instance.
10
+ * Multiple callers waiting concurrently will share the same launch promise.
11
+ */
12
+ export async function getBrowser(): Promise<Browser> {
13
+ if (browser && browser.isConnected()) {
14
+ return browser;
15
+ }
16
+
17
+ if (browserLaunchPromise) {
18
+ return browserLaunchPromise;
19
+ }
20
+
21
+ browserLaunchPromise = launchBrowser();
22
+
23
+ try {
24
+ browser = await browserLaunchPromise;
25
+ return browser;
26
+ } finally {
27
+ browserLaunchPromise = null;
28
+ }
29
+ }
30
+
31
+ async function launchBrowser(): Promise<Browser> {
32
+ logger.info('Launching Chromium browser', { headless: config.headless });
33
+
34
+ const b = await chromium.launch({
35
+ headless: config.headless,
36
+ args: [
37
+ '--no-sandbox',
38
+ '--disable-setuid-sandbox',
39
+ '--disable-dev-shm-usage',
40
+ '--disable-gpu',
41
+ ],
42
+ });
43
+
44
+ b.on('disconnected', () => {
45
+ logger.warn('Browser disconnected');
46
+ browser = null;
47
+ });
48
+
49
+ logger.info('Browser launched successfully');
50
+ return b;
51
+ }
52
+
53
+ /**
54
+ * Create a new isolated browser context for a job.
55
+ * Optionally restore storage state from a previous session.
56
+ */
57
+ export async function createContext(
58
+ storageState?: string
59
+ ): Promise<BrowserContext> {
60
+ const b = await getBrowser();
61
+
62
+ const contextOptions: Parameters<Browser['newContext']>[0] = {
63
+ viewport: { width: 1280, height: 720 },
64
+ userAgent:
65
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
66
+ acceptDownloads: true,
67
+ };
68
+
69
+ if (storageState) {
70
+ try {
71
+ contextOptions.storageState = JSON.parse(storageState);
72
+ } catch {
73
+ logger.warn('Failed to parse storage state, starting fresh');
74
+ }
75
+ }
76
+
77
+ const context = await b.newContext(contextOptions);
78
+
79
+ // Set reasonable timeouts
80
+ context.setDefaultTimeout(30000);
81
+ context.setDefaultNavigationTimeout(60000);
82
+
83
+ return context;
84
+ }
85
+
86
+ /**
87
+ * Gracefully close the shared browser.
88
+ */
89
+ export async function closeBrowser(): Promise<void> {
90
+ if (browser) {
91
+ try {
92
+ await browser.close();
93
+ } catch {
94
+ // Ignore close errors
95
+ }
96
+ browser = null;
97
+ logger.info('Browser closed');
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get count of active contexts (approximation of active jobs).
103
+ */
104
+ export async function getActiveContextCount(): Promise<number> {
105
+ if (!browser || !browser.isConnected()) return 0;
106
+ return browser.contexts().length;
107
+ }
src/worker/manager.ts ADDED
@@ -0,0 +1,1313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from '../config';
2
+ import { logger } from '../utils/logger';
3
+ import { sleep } from '../utils/retry';
4
+ import { createContext } from './browser-pool';
5
+ import { getAccountPassword } from '../crypto/password';
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
+
25
+ import * as path from 'path';
26
+ import * as fs from 'fs';
27
+ import * as os from 'os';
28
+
29
+ let activeWorkers = 0;
30
+ let running = false;
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';
38
+ const LEGACY_ACCOUNT_QUOTA_LIMIT = 4;
39
+
40
+ function accountQuotaLimitForPool(poolKey: string, account: TurnitinAccount): number {
41
+ if (poolKey === MODERN_ONE_POOL_KEY) return 1;
42
+ if (poolKey === LEGACY_ACCOUNT_POOL_KEY) return LEGACY_ACCOUNT_QUOTA_LIMIT;
43
+ return account.turnitin_quota_limit || 3;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // In-memory sets to prevent double processing and double refund.
48
+ // These are per-process so each HF Space worker keeps its own bookkeeping.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Jobs currently being processed by this worker process. */
52
+ const processingJobs = new Set<string>();
53
+
54
+ /**
55
+ * Jobs that have already been refunded by this worker process.
56
+ * NEW-BUG-4 FIX: Entries are auto-deleted after 1 hour to prevent unbounded growth.
57
+ */
58
+ const refundedJobs = new Map<string, NodeJS.Timeout>();
59
+
60
+ function markAsRefunded(jobId: string): void {
61
+ // Clear existing timer if re-refunding (shouldn't happen, but be safe)
62
+ const existing = refundedJobs.get(jobId);
63
+ if (existing) clearTimeout(existing);
64
+ // Auto-remove after 1 hour
65
+ const timer = setTimeout(() => refundedJobs.delete(jobId), 60 * 60 * 1000);
66
+ // Prevent timer from keeping the process alive during shutdown
67
+ if (timer.unref) timer.unref();
68
+ refundedJobs.set(jobId, timer);
69
+ }
70
+
71
+ function wasRefunded(jobId: string): boolean {
72
+ return refundedJobs.has(jobId);
73
+ }
74
+
75
+ async function refundFailedTicketOnce(
76
+ jobId: string,
77
+ reason: string,
78
+ jobLog: ReturnType<typeof logger.child>,
79
+ ): Promise<boolean> {
80
+ if (wasRefunded(jobId)) {
81
+ jobLog.info('Refund already attempted by this worker process', { jobId });
82
+ return false;
83
+ }
84
+
85
+ try {
86
+ const refunded = await refundFailedJob(jobId, reason);
87
+ markAsRefunded(jobId);
88
+ jobLog.info('Failed job refund checked', { jobId, refunded });
89
+ return refunded;
90
+ } catch (error) {
91
+ jobLog.warn('Failed job refund RPC failed', {
92
+ jobId,
93
+ error: error instanceof Error ? compactWorkerMessage(error.message) : String(error),
94
+ });
95
+ return false;
96
+ }
97
+ }
98
+
99
+ type AssignmentTargetConfig = RunTurnitinJobInput['assignmentTarget'];
100
+
101
+ function normalizeUiVariant(value: unknown): AssignmentTargetConfig['uiVariant'] {
102
+ return value === 'legacy_carta' ? 'legacy_carta' : 'modern_lti';
103
+ }
104
+
105
+ function defaultAssignmentTarget(): AssignmentTargetConfig {
106
+ return {
107
+ targetUrl: config.turnitinTargetUrl,
108
+ classTitle: config.turnitinClassTitle,
109
+ assignmentTitle: config.turnitinAssignmentTitle as string | null,
110
+ assignmentLaunchUrl: null,
111
+ uiVariant: 'modern_lti',
112
+ accountPoolKey: DEFAULT_ACCOUNT_POOL_KEY,
113
+ };
114
+ }
115
+
116
+ async function loadAssignmentTarget(
117
+ assignmentTargetId: string,
118
+ jobLog: ReturnType<typeof logger.child>,
119
+ ): Promise<AssignmentTargetConfig> {
120
+ const assignmentTarget = defaultAssignmentTarget();
121
+ if (!assignmentTargetId) return assignmentTarget;
122
+
123
+ try {
124
+ const { data, error } = await supabase
125
+ .from('turnitin_assignment_targets')
126
+ .select('*')
127
+ .eq('id', assignmentTargetId)
128
+ .single();
129
+
130
+ if (error) throw error;
131
+ if (!data) return assignmentTarget;
132
+
133
+ const uiVariant = normalizeUiVariant(data.ui_variant);
134
+ assignmentTarget.targetUrl = data.target_url || assignmentTarget.targetUrl;
135
+ assignmentTarget.classTitle = data.class_title || assignmentTarget.classTitle;
136
+ assignmentTarget.assignmentTitle = data.assignment_title || null;
137
+ assignmentTarget.assignmentLaunchUrl = data.assignment_launch_url || null;
138
+ assignmentTarget.uiVariant = uiVariant;
139
+ assignmentTarget.accountPoolKey =
140
+ data.account_pool_key || (uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY);
141
+
142
+ return assignmentTarget;
143
+ } catch (error) {
144
+ jobLog.warn('Failed to fetch assignment target, using defaults', {
145
+ assignmentTargetId,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ });
148
+ return assignmentTarget;
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Job queue with backpressure — prevents thundering herd
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Pending account claim resolvers, FIFO.
158
+ * When a job needs an account and none is free, it pushes a resolve callback
159
+ * here. When any job finishes and releases an account, we pop the first waiter
160
+ * to signal it to retry its claim.
161
+ */
162
+ const accountWaiters: Array<() => void> = [];
163
+
164
+ /** Signal one waiting job that an account may now be available. */
165
+ function notifyNextWaiter(): void {
166
+ const next = accountWaiters.shift();
167
+ if (next) next();
168
+ }
169
+
170
+ /** Wait for a notification that an account was released. */
171
+ function waitForAccountRelease(timeoutMs: number): Promise<'notified' | 'timeout'> {
172
+ return new Promise((resolve) => {
173
+ const timer = setTimeout(() => {
174
+ // Remove ourselves from the queue on timeout
175
+ const idx = accountWaiters.indexOf(onNotify);
176
+ if (idx >= 0) accountWaiters.splice(idx, 1);
177
+ resolve('timeout');
178
+ }, timeoutMs);
179
+ if (timer.unref) timer.unref();
180
+
181
+ const onNotify = () => {
182
+ clearTimeout(timer);
183
+ resolve('notified');
184
+ };
185
+ accountWaiters.push(onNotify);
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Start the worker polling loop.
191
+ * Claims jobs from Supabase and processes them with Playwright.
192
+ */
193
+ export async function startWorkerManager(): Promise<void> {
194
+ running = true;
195
+ logger.info('Worker manager started', {
196
+ workerId: config.workerId,
197
+ maxWorkers: config.maxWorkers,
198
+ });
199
+
200
+ // BUG-2 FIX: Stale job recovery is now handled EXCLUSIVELY by stale-recovery.ts
201
+ // cron job, which runs every 5 minutes. Removing the duplicate inline loop here
202
+ // prevents two Space workers from simultaneously resetting a job that the other
203
+ // worker is still actively processing.
204
+
205
+ while (running) {
206
+ try {
207
+ if (activeWorkers >= config.maxWorkers) {
208
+ await sleep(2000);
209
+ continue;
210
+ }
211
+
212
+ // Try to claim a pending job
213
+ const job = await claimPendingJob(config.workerId);
214
+
215
+ if (!job) {
216
+ // No jobs available, wait before polling again
217
+ const jitter = config.pollIntervalMs + Math.random() * 2000;
218
+ await sleep(jitter);
219
+ continue;
220
+ }
221
+
222
+ // ── Guard: skip job if already being processed by this worker ──
223
+ if (processingJobs.has(job.id)) {
224
+ logger.warn('Skipping job already in progress on this worker', {
225
+ jobId: job.id,
226
+ });
227
+ // Release the claim — set it back to pending so a healthy worker can take it.
228
+ await updateJobStatus(job.id, 'pending').catch(() => {});
229
+ await sleep(1000);
230
+ continue;
231
+ }
232
+
233
+ logger.info('Claimed job', { jobId: job.id, mode: job.mode, userId: job.user_id });
234
+
235
+ // ── BUG-3 FIX: Defensive guard — verify the claimed job actually belongs ──
236
+ // to this worker. If the Supabase RPC `claim_turnitin_job` accidentally
237
+ // returns a job owned by another worker (e.g. one stuck in 'waiting_account'),
238
+ // processing it here would cause double-submit. Re-read the job from DB to
239
+ // ensure worker_id matches before proceeding.
240
+ const freshJob = await getJobById(job.id);
241
+ if (freshJob && freshJob.worker_id && freshJob.worker_id !== config.workerId) {
242
+ logger.warn('Claimed job belongs to a different worker; releasing', {
243
+ jobId: job.id,
244
+ ownWorker: config.workerId,
245
+ actualWorker: freshJob.worker_id,
246
+ });
247
+ await sleep(1000);
248
+ continue;
249
+ }
250
+
251
+ // Mark as in-progress
252
+ processingJobs.add(job.id);
253
+
254
+ // Spawn async task for this job (non-blocking)
255
+ activeWorkers++;
256
+ processJob(job)
257
+ .catch((err) => {
258
+ logger.error('Unhandled error in job processing', {
259
+ jobId: job.id,
260
+ error: err instanceof Error ? err.message : String(err),
261
+ });
262
+ })
263
+ .finally(() => {
264
+ activeWorkers--;
265
+ processingJobs.delete(job.id);
266
+ });
267
+ } catch (err) {
268
+ logger.error('Worker manager loop error', {
269
+ error: err instanceof Error ? err.message : String(err),
270
+ });
271
+ await sleep(5000);
272
+ }
273
+ }
274
+
275
+ logger.info('Worker manager stopped');
276
+ }
277
+
278
+
279
+ /**
280
+ * Stop the worker polling loop gracefully.
281
+ */
282
+ export function stopWorkerManager(): void {
283
+ running = false;
284
+ logger.info('Worker manager stop requested');
285
+ }
286
+
287
+ /**
288
+ * Get current active worker count.
289
+ */
290
+ export function getActiveWorkerCount(): number {
291
+ return activeWorkers;
292
+ }
293
+
294
+ /**
295
+ * Process a single job end-to-end.
296
+ */
297
+ async function processJob(job: TurnitinJob): Promise<void> {
298
+ const jobId = job.id;
299
+ const jobLog = logger.child({ job: jobId });
300
+ let identityId: string | null = null;
301
+ let claimedAccount: TurnitinAccount | null = null;
302
+ let initialLastCompletedStep: string | undefined;
303
+ let currentAttemptCount = (job.attempt_count as number) || 0;
304
+ const maxAttempts = (job.max_attempts as number) || 3;
305
+
306
+ // NEW-BUG-3 FIX: Declare tmpDir outside try so the finally block can always clean up.
307
+ const tmpDir = path.join(os.tmpdir(), `turnitin-job-${jobId}`);
308
+ const assignmentTargetId = job.assignment_target_id as string;
309
+ let assignmentTarget = defaultAssignmentTarget();
310
+ let accountPoolKey = DEFAULT_ACCOUNT_POOL_KEY;
311
+
312
+ try {
313
+ // ── Re-check job status before processing ──
314
+ // Another worker may have already picked this job up or cancelled it.
315
+ const freshJob = await getJobById(jobId);
316
+ if (!freshJob || !['claiming_account', 'pending'].includes(freshJob.status)) {
317
+ jobLog.warn('Job no longer claimable; skipping', {
318
+ currentStatus: freshJob?.status ?? 'not_found',
319
+ });
320
+ return;
321
+ }
322
+
323
+ // Increment attempt count
324
+ currentAttemptCount = await incrementJobAttempt(jobId);
325
+
326
+ // If a previous attempt already submitted the file, keep retrying with
327
+ // the same Turnitin account. A different account generally cannot access
328
+ // the existing report viewer URL and must not submit the file again.
329
+ initialLastCompletedStep = freshJob.last_completed_step || undefined;
330
+ const resumeNeedsSameAccount = Boolean(
331
+ initialLastCompletedStep &&
332
+ RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) &&
333
+ freshJob.identity_id,
334
+ );
335
+ assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog);
336
+ accountPoolKey =
337
+ assignmentTarget.accountPoolKey ||
338
+ (assignmentTarget.uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY);
339
+
340
+ // Claim an available Turnitin account
341
+ await updateJobStatus(jobId, 'claiming_account');
342
+ await emitEvent(jobId, null, 'info', 'claiming_account', 'Looking for available Turnitin account', {
343
+ 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),
375
+ next_retry_at: new Date(Date.now() + RESUME_ACCOUNT_RETRY_DELAY_MS).toISOString(),
376
+ });
377
+ await emitEvent(jobId, freshJob.identity_id, 'warning', 'waiting_account', message);
378
+ }
379
+ return;
380
+ }
381
+
382
+ claimedAccount = account;
383
+ identityId = account.id;
384
+ jobLog.info('Account claimed', { accountId: identityId, email: account.email });
385
+
386
+ if (
387
+ accountPoolKey === MODERN_ONE_POOL_KEY &&
388
+ (
389
+ account.turnitin_quota_limit !== 1 ||
390
+ typeof account.turnitin_quota_remaining !== 'number' ||
391
+ account.turnitin_quota_remaining > 1
392
+ )
393
+ ) {
394
+ await updateAccountQuota(identityId, {
395
+ turnitin_quota_limit: 1,
396
+ turnitin_quota_remaining:
397
+ typeof account.turnitin_quota_remaining === 'number'
398
+ ? Math.min(1, account.turnitin_quota_remaining)
399
+ : 1,
400
+ }).catch(() => {});
401
+ account.turnitin_quota_limit = 1;
402
+ account.turnitin_quota_remaining =
403
+ typeof account.turnitin_quota_remaining === 'number'
404
+ ? Math.min(1, account.turnitin_quota_remaining)
405
+ : 1;
406
+ }
407
+
408
+ if (
409
+ accountPoolKey === LEGACY_ACCOUNT_POOL_KEY &&
410
+ (
411
+ account.turnitin_quota_limit !== LEGACY_ACCOUNT_QUOTA_LIMIT ||
412
+ typeof account.turnitin_quota_remaining !== 'number' ||
413
+ account.turnitin_quota_remaining > LEGACY_ACCOUNT_QUOTA_LIMIT
414
+ )
415
+ ) {
416
+ await updateAccountQuota(identityId, {
417
+ turnitin_quota_limit: LEGACY_ACCOUNT_QUOTA_LIMIT,
418
+ turnitin_quota_remaining:
419
+ typeof account.turnitin_quota_remaining === 'number'
420
+ ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining)
421
+ : LEGACY_ACCOUNT_QUOTA_LIMIT,
422
+ }).catch(() => {});
423
+ account.turnitin_quota_limit = LEGACY_ACCOUNT_QUOTA_LIMIT;
424
+ account.turnitin_quota_remaining =
425
+ typeof account.turnitin_quota_remaining === 'number'
426
+ ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining)
427
+ : LEGACY_ACCOUNT_QUOTA_LIMIT;
428
+ }
429
+
430
+ // Update job with account reference
431
+ await updateJobStatus(jobId, 'running', {
432
+ identity_id: identityId,
433
+ started_at: new Date().toISOString(),
434
+ });
435
+ await emitEvent(jobId, identityId, 'info', 'running', `Starting job with account ${account.email}`);
436
+
437
+ // Create temp directory for this job
438
+ fs.mkdirSync(tmpDir, { recursive: true });
439
+
440
+ // Download input file from Supabase Storage, except for quota_check jobs.
441
+ const localInputPath = job.mode === 'quota_check'
442
+ ? ''
443
+ : path.join(tmpDir, job.input_file_name as string);
444
+ if (job.mode !== 'quota_check') {
445
+ await downloadInputFile(job.input_file_path as string, localInputPath);
446
+ await emitEvent(jobId, identityId, 'info', 'file_downloaded', 'Input file downloaded');
447
+ }
448
+
449
+ // Get storage state for session reuse
450
+ let storageStatePath: string | undefined;
451
+ if (account.turnitin_session_storage_path) {
452
+ const storageState = await downloadStorageState(account.turnitin_session_storage_path);
453
+ if (storageState) {
454
+ storageStatePath = path.join(tmpDir, 'storageState.json');
455
+ fs.writeFileSync(storageStatePath, storageState, 'utf-8');
456
+ }
457
+ }
458
+
459
+ // Get password
460
+ const password = getAccountPassword();
461
+
462
+ // Parse filters from job
463
+ const filters = (job.filters as Record<string, unknown>) || {};
464
+
465
+ // ── Determine resume point ──
466
+ // If a previous attempt already uploaded the file, we must NOT upload
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
+
474
+ if (lastCompletedStep) {
475
+ jobLog.info('Resuming job from previous step', {
476
+ lastCompletedStep,
477
+ effectiveMode,
478
+ });
479
+ }
480
+
481
+ // Prepare job input
482
+ const jobInput: RunTurnitinJobInput = {
483
+ account: {
484
+ id: identityId,
485
+ email: account.email,
486
+ password,
487
+ quotaLimit: accountQuotaLimitForPool(accountPoolKey, account),
488
+ quotaRemaining:
489
+ typeof account.turnitin_quota_remaining === 'number'
490
+ ? account.turnitin_quota_remaining
491
+ : null,
492
+ },
493
+ assignmentTarget,
494
+ inputFilePath: localInputPath,
495
+ inputFileName: job.input_file_name as string,
496
+ inputFileSize: job.input_file_size ?? undefined,
497
+ outputDir: tmpDir,
498
+ mode: effectiveMode,
499
+ filters: {
500
+ excludeBibliography: filters.excludeBibliography as boolean | undefined,
501
+ excludeQuotes: filters.excludeQuotes as boolean | undefined,
502
+ excludeCitations: filters.excludeCitations as boolean | undefined,
503
+ excludeSmallMatches: filters.excludeSmallMatches as boolean | undefined,
504
+ smallMatchMode: filters.smallMatchMode as 'words' | 'percent' | 'off' | null | undefined,
505
+ smallMatchThreshold: filters.smallMatchThreshold as number | null | undefined,
506
+ },
507
+ storageStatePath,
508
+ resumeAfterStep: lastCompletedStep,
509
+ resumeViewerUrl: freshJob.viewer_url,
510
+ attemptCount: currentAttemptCount,
511
+ onEvent: async (event) => {
512
+ await emitEvent(jobId, identityId, event.level, event.step, event.message, event.metadata);
513
+ await persistJobProgressFromEvent(jobId, event);
514
+ },
515
+ };
516
+
517
+ // Run the Playwright job
518
+ const result = await runTurnitinJob(jobInput);
519
+
520
+ // Handle quota limit
521
+ if (result.quotaLimit) {
522
+ jobLog.warn('Account quota limited', { limit: result.quotaLimit });
523
+ await updateAccountQuota(identityId, {
524
+ turnitin_status: 'quota_limited',
525
+ turnitin_quota_remaining: 0,
526
+ turnitin_quota_message: result.quotaLimit.message,
527
+ turnitin_quota_detected_at: new Date().toISOString(),
528
+ turnitin_next_retry_at:
529
+ accountPoolKey === MODERN_ONE_POOL_KEY
530
+ ? null
531
+ : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
532
+ });
533
+ await releaseAccount(identityId, 'quota_limited', result.quotaLimit.message);
534
+
535
+ const availableAccounts = await countAvailableAccounts(accountPoolKey);
536
+ // Retry with a different account only when one is immediately available.
537
+ if (currentAttemptCount < maxAttempts && availableAccounts > 0) {
538
+ await updateJobStatus(jobId, 'pending');
539
+ await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep);
540
+ await emitEvent(jobId, identityId, 'warning', 'quota_limited', 'Account quota limited, retrying with different account');
541
+ } else {
542
+ await updateJobStatus(jobId, 'failed', {
543
+ error_message: result.quotaLimit.message,
544
+ finished_at: new Date().toISOString(),
545
+ });
546
+ const refunded = await refundFailedTicketOnce(
547
+ jobId,
548
+ result.quotaLimit.message,
549
+ jobLog,
550
+ );
551
+ await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep);
552
+ await emitEvent(jobId, identityId, 'error', 'quota_limited', result.quotaLimit.message, {
553
+ refunded,
554
+ });
555
+ }
556
+ return;
557
+ }
558
+
559
+ // Upload PDF report if available
560
+ let outputPdfPath: string | undefined;
561
+ let outputPdfExpiresAt: string | undefined;
562
+
563
+ if (result.outputPdfPath && fs.existsSync(result.outputPdfPath)) {
564
+ const uploadResult = await uploadReportPdf(
565
+ job.user_id as string,
566
+ jobId,
567
+ result.outputPdfPath
568
+ );
569
+ outputPdfPath = uploadResult.storagePath;
570
+ outputPdfExpiresAt = uploadResult.expiresAt;
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
597
+ const stateFile = path.join(tmpDir, 'storageState.json');
598
+ if (fs.existsSync(stateFile)) {
599
+ const stateJson = fs.readFileSync(stateFile, 'utf-8');
600
+ const storagePath = await uploadStorageState(identityId, stateJson);
601
+ await updateAccountQuota(identityId, {
602
+ turnitin_session_storage_path: storagePath,
603
+ });
604
+ }
605
+ } catch {
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>,
637
+ });
638
+ }
639
+ await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep || 'download');
640
+
641
+ // Insert submission record
642
+ try {
643
+ const { supabase } = await import('../db/client');
644
+ await supabase.from('turnitin_submissions').insert({
645
+ job_id: jobId,
646
+ identity_id: identityId,
647
+ assignment_target_id: assignmentTargetId,
648
+ input_file_name: job.input_file_name as string,
649
+ input_file_size: job.input_file_size,
650
+ input_file_sha256: job.input_file_sha256,
651
+ viewer_url: result.viewerUrl,
652
+ similarity_percent: result.similarityPercent,
653
+ submission_details: result.submissionDetails as Record<string, unknown> | undefined,
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) {
662
+ jobLog.error('Failed to insert submission record', {
663
+ error: err instanceof Error ? err.message : String(err),
664
+ });
665
+ }
666
+
667
+ // Release account. A final-submission warning means this successful submit
668
+ // likely consumed the last available submission, so keep the account out of
669
+ // rotation until the cooldown window passes.
670
+ if (result.permanentLimit) {
671
+ await updateAccountQuota(identityId, {
672
+ turnitin_status: 'quota_limited',
673
+ turnitin_quota_limit: accountQuotaLimitForPool(accountPoolKey, account),
674
+ turnitin_quota_remaining: 0,
675
+ turnitin_quota_message: result.permanentLimit.message,
676
+ turnitin_quota_detected_at: new Date().toISOString(),
677
+ turnitin_next_retry_at: null,
678
+ turnitin_last_success_at: new Date().toISOString(),
679
+ });
680
+ await releaseAccount(identityId, 'quota_limited', result.permanentLimit.message);
681
+ } else if (result.quotaCooldown) {
682
+ await updateAccountQuota(identityId, {
683
+ turnitin_status: 'cooling_down',
684
+ turnitin_quota_remaining: null,
685
+ turnitin_quota_message: result.quotaCooldown.message,
686
+ turnitin_quota_detected_at: new Date().toISOString(),
687
+ turnitin_next_retry_at: result.quotaCooldown.nextRetryAt,
688
+ turnitin_last_success_at: new Date().toISOString(),
689
+ });
690
+ await releaseAccount(identityId, 'cooling_down', result.quotaCooldown.message);
691
+ } else if (result.quotaWarning) {
692
+ await updateAccountQuota(identityId, {
693
+ turnitin_status: 'quota_limited',
694
+ turnitin_quota_remaining: 0,
695
+ turnitin_quota_message: result.quotaWarning,
696
+ turnitin_quota_detected_at: new Date().toISOString(),
697
+ turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
698
+ });
699
+ await releaseAccount(identityId, 'quota_limited', result.quotaWarning);
700
+ } else {
701
+ const remainingFromSubmissionCount =
702
+ typeof result.submissionCount === 'number'
703
+ ? Math.max(0, accountQuotaLimitForPool(accountPoolKey, account) - result.submissionCount)
704
+ : null;
705
+ const currentRemaining =
706
+ typeof account.turnitin_quota_remaining === 'number'
707
+ ? account.turnitin_quota_remaining
708
+ : accountQuotaLimitForPool(accountPoolKey, account);
709
+ const nextRemaining =
710
+ remainingFromSubmissionCount ?? Math.max(0, currentRemaining - 1);
711
+ if (assignmentTarget.uiVariant === LEGACY_ACCOUNT_POOL_KEY && nextRemaining <= 0) {
712
+ const cooldownMessage =
713
+ 'Legacy Turnitin account reached 4 submissions. Account is permanently limited because the class should be dropped.';
714
+ await updateAccountQuota(identityId, {
715
+ turnitin_status: 'quota_limited',
716
+ turnitin_quota_remaining: 0,
717
+ turnitin_quota_message: cooldownMessage,
718
+ turnitin_quota_detected_at: new Date().toISOString(),
719
+ turnitin_next_retry_at: null,
720
+ turnitin_last_success_at: new Date().toISOString(),
721
+ });
722
+ await releaseAccount(identityId, 'quota_limited', cooldownMessage);
723
+ } else if (accountPoolKey === MODERN_ONE_POOL_KEY && nextRemaining <= 0) {
724
+ const oneUseMessage =
725
+ 'Modern one-use account consumed its single allowed submission. Account is permanently limited.';
726
+ await updateAccountQuota(identityId, {
727
+ turnitin_status: 'quota_limited',
728
+ turnitin_quota_limit: 1,
729
+ turnitin_quota_remaining: 0,
730
+ turnitin_quota_message: oneUseMessage,
731
+ turnitin_quota_detected_at: new Date().toISOString(),
732
+ turnitin_next_retry_at: null,
733
+ turnitin_last_success_at: new Date().toISOString(),
734
+ });
735
+ await releaseAccount(identityId, 'quota_limited', oneUseMessage);
736
+ } else {
737
+ await updateAccountQuota(identityId, {
738
+ turnitin_status: 'available',
739
+ turnitin_quota_remaining: nextRemaining,
740
+ turnitin_quota_message: null,
741
+ turnitin_next_retry_at: null,
742
+ turnitin_last_success_at: new Date().toISOString(),
743
+ });
744
+ await releaseAccount(identityId, 'available');
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) {
767
+ const errorMessage = err instanceof Error ? err.message : String(err);
768
+ const publicErrorMessage = compactWorkerMessage(errorMessage);
769
+ logger.error('Job failed', { jobId, error: publicErrorMessage });
770
+
771
+ // Try to extract lastCompletedStep from the engine result (it's set
772
+ // on the result object even before throwing because the steps record
773
+ // progress incrementally). We also check the error object itself in
774
+ // case the engine attached the step there.
775
+ const failedResultStep: string | undefined =
776
+ (err as any)?.lastCompletedStep ||
777
+ undefined;
778
+ const failedViewerUrl: string | undefined = (err as any)?.viewerUrl || undefined;
779
+ const failedSimilarityPercent: number | undefined =
780
+ typeof (err as any)?.similarityPercent === 'number'
781
+ ? (err as any).similarityPercent
782
+ : undefined;
783
+
784
+ const isQuotaLimit =
785
+ err instanceof Error &&
786
+ (err.name === 'SubmissionQuotaLimitError' ||
787
+ /reached your limit|submission quota limit/i.test(errorMessage));
788
+ const isTargetUnavailable =
789
+ /waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test(
790
+ errorMessage,
791
+ );
792
+
793
+ // Determine whether the file was already uploaded on THIS attempt.
794
+ // If so, subsequent retries MUST NOT re-upload.
795
+ // We detect this from the job status transitions that went through
796
+ // the emitEvent calls above (e.g. the 'submitted' event).
797
+ let stepToSave: string | null = failedResultStep || null;
798
+ if (!stepToSave) {
799
+ // Fallback: if the error happened after similarity/viewer/download
800
+ // the job status will have been updated through updateJobStatus.
801
+ const latestJob = await getJobById(jobId).catch(() => null);
802
+ stepToSave = latestJob?.last_completed_step || null;
803
+ }
804
+
805
+ const failureProgress: Partial<TurnitinJob> = {};
806
+ if (stepToSave) failureProgress.last_completed_step = stepToSave;
807
+ if (failedViewerUrl) failureProgress.viewer_url = failedViewerUrl;
808
+ if (typeof failedSimilarityPercent === 'number') {
809
+ failureProgress.similarity_percent = failedSimilarityPercent;
810
+ }
811
+ if (Object.keys(failureProgress).length > 0) {
812
+ await safeUpdateJobFields(jobId, failureProgress);
813
+ }
814
+
815
+ const isPostSubmitResume = Boolean(
816
+ stepToSave && RESUME_PROTECTED_STEPS.includes(stepToSave),
817
+ );
818
+ const availableAccounts =
819
+ isQuotaLimit || isTargetUnavailable
820
+ ? await countAvailableAccounts(accountPoolKey).catch(() => 0)
821
+ : 1;
822
+ const shouldRetry =
823
+ currentAttemptCount < maxAttempts &&
824
+ (!(isQuotaLimit || isTargetUnavailable) || availableAccounts > 0);
825
+ const submissionConsumedThisAttempt = Boolean(
826
+ isPostSubmitResume && !initialLastCompletedStep,
827
+ );
828
+ const knownRemaining =
829
+ typeof claimedAccount?.turnitin_quota_remaining === 'number'
830
+ ? claimedAccount.turnitin_quota_remaining
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) {
846
+ const isLoginError = errorMessage.toLowerCase().includes('login');
847
+ try {
848
+ if (isQuotaLimit) {
849
+ await updateAccountQuota(identityId, {
850
+ turnitin_status: 'quota_limited',
851
+ turnitin_quota_remaining: 0,
852
+ turnitin_quota_message: errorMessage,
853
+ turnitin_quota_detected_at: new Date().toISOString(),
854
+ turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
855
+ turnitin_last_error: errorMessage,
856
+ });
857
+ await releaseAccount(identityId, 'quota_limited', errorMessage);
858
+ } else if (isTargetUnavailable) {
859
+ await updateAccountQuota(identityId, {
860
+ turnitin_status: 'quota_limited',
861
+ turnitin_quota_remaining: 0,
862
+ turnitin_quota_message:
863
+ 'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.',
864
+ turnitin_quota_detected_at: new Date().toISOString(),
865
+ turnitin_next_retry_at: null,
866
+ turnitin_last_error: errorMessage,
867
+ });
868
+ await releaseAccount(identityId, 'quota_limited', errorMessage);
869
+ } else if (isLoginError) {
870
+ const loginLimitMessage =
871
+ 'Turnitin login failed. Account removed from rotation because the credential is invalid or the account no longer exists.';
872
+ await updateAccountQuota(identityId, {
873
+ turnitin_status: 'quota_limited',
874
+ turnitin_quota_remaining: 0,
875
+ turnitin_quota_message: loginLimitMessage,
876
+ turnitin_quota_detected_at: new Date().toISOString(),
877
+ turnitin_next_retry_at: null,
878
+ turnitin_last_error: errorMessage,
879
+ }).catch(() => {});
880
+ await releaseAccount(identityId, 'quota_limited', errorMessage);
881
+ } else {
882
+ let nextStatus = 'available';
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
+ }
894
+
895
+ if (submissionConsumedThisAttempt) {
896
+ await updateAccountQuota(identityId, {
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,
912
+ }).catch(() => {});
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);
939
+ }
940
+ } catch {
941
+ // Ignore release errors
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', {
958
+ error_message: publicErrorMessage,
959
+ });
960
+ await safeUpdateLastCompletedStep(jobId, stepToSave);
961
+ await emitEvent(jobId, identityId, 'error', 'failed_retry', `Attempt failed, will retry: ${publicErrorMessage}`);
962
+ } else {
963
+ await updateJobStatus(jobId, 'failed', {
964
+ error_message: publicErrorMessage,
965
+ finished_at: new Date().toISOString(),
966
+ });
967
+ const refunded = await refundFailedTicketOnce(jobId, publicErrorMessage, jobLog);
968
+ await safeUpdateLastCompletedStep(jobId, stepToSave);
969
+ await emitEvent(
970
+ jobId,
971
+ identityId,
972
+ 'error',
973
+ 'failed_final',
974
+ refunded
975
+ ? `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}. Ticket refunded.`
976
+ : `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}`,
977
+ { refunded },
978
+ );
979
+ }
980
+ } finally {
981
+ // NEW-BUG-3 FIX: Always clean up temp dir regardless of success or error
982
+ try {
983
+ if (fs.existsSync(tmpDir)) {
984
+ fs.rmSync(tmpDir, { recursive: true, force: true });
985
+ }
986
+ } catch {
987
+ // Ignore cleanup errors
988
+ }
989
+
990
+ // Notify the next waiting job that an account may have been released.
991
+ // This is part of the backpressure system — instead of all waiters
992
+ // polling simultaneously (thundering herd), they wait in a FIFO queue.
993
+ notifyNextWaiter();
994
+ }
995
+ }
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // Account claiming with bounded wait & single-refund guarantee
999
+ // ---------------------------------------------------------------------------
1000
+
1001
+ async function claimAccountForJob(
1002
+ job: TurnitinJob,
1003
+ identityId: string | null,
1004
+ jobLog: ReturnType<typeof logger.child>,
1005
+ accountPoolKey: string,
1006
+ ): Promise<TurnitinAccount | null> {
1007
+ const startedAt = Date.now();
1008
+ let lastWaitingEventAt = 0;
1009
+
1010
+ while (true) {
1011
+ // ── Re-check that the job is still ours ──
1012
+ // CRITICAL: also check worker_id to catch the case where another Space
1013
+ // worker already picked this job while we were sleeping.
1014
+ const freshJob = await getJobById(job.id).catch(() => null);
1015
+ if (!freshJob) {
1016
+ jobLog.warn('Job disappeared while waiting for account', { jobId: job.id });
1017
+ return null;
1018
+ }
1019
+ if (['cancelled', 'completed', 'failed'].includes(freshJob.status)) {
1020
+ jobLog.info('Job moved to terminal state while waiting for account; stopping', {
1021
+ jobId: job.id,
1022
+ status: freshJob.status,
1023
+ });
1024
+ return null;
1025
+ }
1026
+ // If another worker took ownership of this job, stop immediately.
1027
+ if (freshJob.worker_id && freshJob.worker_id !== config.workerId) {
1028
+ jobLog.warn('Job was taken by another worker while waiting for account; stopping', {
1029
+ jobId: job.id,
1030
+ ownedBy: freshJob.worker_id,
1031
+ thisWorker: config.workerId,
1032
+ });
1033
+ return null;
1034
+ }
1035
+
1036
+ const account = await claimAvailableAccount(config.workerId, accountPoolKey);
1037
+ if (account) {
1038
+ // Double-check ownership one final time to prevent double-processing
1039
+ // that can happen when both workers exit the wait loop simultaneously.
1040
+ const jobAfterClaim = await getJobById(job.id).catch(() => null);
1041
+ if (
1042
+ !jobAfterClaim ||
1043
+ ['cancelled', 'completed', 'failed'].includes(jobAfterClaim.status) ||
1044
+ (jobAfterClaim.worker_id && jobAfterClaim.worker_id !== config.workerId)
1045
+ ) {
1046
+ jobLog.warn('Job ownership lost right after claiming account; releasing account and stopping', {
1047
+ jobId: job.id,
1048
+ status: jobAfterClaim?.status,
1049
+ ownedBy: jobAfterClaim?.worker_id,
1050
+ });
1051
+ // Release the just-claimed account back to the pool
1052
+ await releaseAccount(account.id, 'available').catch(() => {});
1053
+ return null;
1054
+ }
1055
+ return account;
1056
+ }
1057
+
1058
+ const poolState = await getAccountPoolState(accountPoolKey);
1059
+
1060
+ // ── Wait only if there are running accounts AND we haven't timed out ──
1061
+ if (poolState.running > 0 && Date.now() - startedAt < ACCOUNT_WAIT_MAX_MS) {
1062
+ const message = `No free account right now. Waiting for ${poolState.running} running account(s) to finish.`;
1063
+ // Keep status as waiting_account — do NOT reset to claiming_account here.
1064
+ // Resetting to claiming_account would allow another worker's claim_turnitin_job
1065
+ // RPC to pick up this job, causing double processing.
1066
+ await updateJobStatus(job.id, 'waiting_account', {
1067
+ error_message: message,
1068
+ });
1069
+
1070
+ if (Date.now() - lastWaitingEventAt > 60000) {
1071
+ lastWaitingEventAt = Date.now();
1072
+ await emitEvent(job.id, identityId, 'warning', 'waiting_account', message, {
1073
+ poolState,
1074
+ });
1075
+ }
1076
+
1077
+ // BACKPRESSURE: Instead of fixed-interval polling (sleep 15s), wait
1078
+ // in the FIFO queue for a notification from a finishing job. Fall back
1079
+ // to the old sleep interval if no notification arrives.
1080
+ await waitForAccountRelease(ACCOUNT_WAIT_POLL_MS);
1081
+ // Do NOT reset to claiming_account — keep as waiting_account
1082
+ continue;
1083
+ }
1084
+
1085
+ // ── No accounts available and nothing running (or timed out) ──
1086
+ // Cancel the job and refund the ticket once. The database RPC is
1087
+ // idempotent, while the local marker only prevents duplicate attempts
1088
+ // after a successful cancellation/refund.
1089
+ if (wasRefunded(job.id)) {
1090
+ jobLog.warn('Refund already issued for this job; skipping duplicate refund', {
1091
+ jobId: job.id,
1092
+ });
1093
+ // Set job to failed terminal state so neither worker picks it up again.
1094
+ await updateJobStatus(job.id, 'failed', {
1095
+ error_message: 'All Turnitin accounts are out of quota or cooling down. Ticket already refunded.',
1096
+ finished_at: new Date().toISOString(),
1097
+ });
1098
+ return null;
1099
+ }
1100
+
1101
+ const cancelMessage =
1102
+ poolState.total === 0
1103
+ ? 'No Turnitin accounts are configured. Ticket refunded.'
1104
+ : 'All Turnitin accounts are out of quota or cooling down. Ticket refunded.';
1105
+
1106
+ jobLog.warn('No claimable Turnitin accounts; cancelling and refunding ticket', {
1107
+ poolState,
1108
+ });
1109
+
1110
+ try {
1111
+ const cancelled = await cancelJob(job.user_id as string, job.id);
1112
+ if (cancelled) {
1113
+ markAsRefunded(job.id);
1114
+ }
1115
+ await emitEvent(job.id, identityId, 'error', 'no_account', cancelMessage, {
1116
+ poolState,
1117
+ refunded: cancelled,
1118
+ });
1119
+ } catch (error) {
1120
+ const message =
1121
+ error instanceof Error ? compactWorkerMessage(error.message) : String(error);
1122
+
1123
+ // If the cancel RPC failed because the job is in a wrong state (e.g.
1124
+ // already cancelled by the other worker), just mark it failed.
1125
+ jobLog.error('Failed to cancel job', {
1126
+ userId: job.user_id,
1127
+ jobId: job.id,
1128
+ error: message,
1129
+ });
1130
+
1131
+ await updateJobStatus(job.id, 'failed', {
1132
+ error_message: `${cancelMessage} Refund note: ${message}`,
1133
+ finished_at: new Date().toISOString(),
1134
+ }).catch(() => {});
1135
+ const refunded = await refundFailedTicketOnce(
1136
+ job.id,
1137
+ `${cancelMessage} Fallback after cancel error: ${message}`,
1138
+ jobLog,
1139
+ );
1140
+
1141
+ await emitEvent(
1142
+ job.id,
1143
+ identityId,
1144
+ 'error',
1145
+ 'no_account',
1146
+ refunded
1147
+ ? `${cancelMessage} Refund recovered after cancel error.`
1148
+ : `${cancelMessage} Refund note: ${message}`,
1149
+ { poolState, refunded },
1150
+ );
1151
+ }
1152
+
1153
+ return null;
1154
+ }
1155
+ }
1156
+
1157
+ function compactWorkerMessage(message: string): string {
1158
+ const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
1159
+ if (/locator\.waitFor: Timeout/i.test(firstLine)) {
1160
+ const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
1161
+ return selector
1162
+ ? `Turnitin page element did not appear in time: ${selector}`
1163
+ : 'Turnitin page element did not appear in time.';
1164
+ }
1165
+ if (/Upload file was selected, but no Upload\/Confirm\/Submit button was found/i.test(firstLine)) {
1166
+ return 'File was selected, but Turnitin did not show a usable upload confirmation button.';
1167
+ }
1168
+ return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
1169
+ }
1170
+
1171
+ /**
1172
+ * Helper to emit a job event.
1173
+ */
1174
+ async function emitEvent(
1175
+ jobId: string,
1176
+ identityId: string | null,
1177
+ level: 'info' | 'warning' | 'error',
1178
+ step: string,
1179
+ message: string,
1180
+ metadata?: Record<string, unknown>
1181
+ ): Promise<void> {
1182
+ try {
1183
+ await insertJobEvent({
1184
+ job_id: jobId,
1185
+ identity_id: identityId,
1186
+ level,
1187
+ step,
1188
+ message,
1189
+ metadata: metadata || {},
1190
+ });
1191
+ } catch (err) {
1192
+ logger.error('Failed to insert job event', {
1193
+ jobId,
1194
+ step,
1195
+ error: err instanceof Error ? err.message : String(err),
1196
+ });
1197
+ }
1198
+ }
1199
+
1200
+ type EngineEvent = {
1201
+ level: 'info' | 'warning' | 'error';
1202
+ step: string;
1203
+ message: string;
1204
+ metadata?: Record<string, unknown>;
1205
+ };
1206
+
1207
+ async function persistJobProgressFromEvent(
1208
+ jobId: string,
1209
+ event: EngineEvent,
1210
+ ): Promise<void> {
1211
+ const fields: Partial<TurnitinJob> = {};
1212
+
1213
+ if (event.step === 'submitted' && event.level === 'info') {
1214
+ fields.last_completed_step = 'submitted';
1215
+ } else if (
1216
+ event.step === 'submission_details' &&
1217
+ event.level === 'info' &&
1218
+ event.metadata
1219
+ ) {
1220
+ fields.submission_details = event.metadata;
1221
+ } else if (
1222
+ event.step === 'similarity' &&
1223
+ event.level === 'info' &&
1224
+ /^Similarity:/i.test(event.message)
1225
+ ) {
1226
+ fields.last_completed_step = 'similarity';
1227
+ if (typeof event.metadata?.similarityPercent === 'number') {
1228
+ fields.similarity_percent = event.metadata.similarityPercent;
1229
+ }
1230
+ } else if (
1231
+ event.step === 'similarity' &&
1232
+ event.level === 'info' &&
1233
+ typeof event.metadata?.similarityPercent === 'number'
1234
+ ) {
1235
+ fields.similarity_percent = event.metadata.similarityPercent;
1236
+ } else if (
1237
+ event.step === 'viewer' &&
1238
+ event.level === 'info' &&
1239
+ typeof event.metadata?.viewerUrl === 'string'
1240
+ ) {
1241
+ fields.last_completed_step = 'viewer';
1242
+ fields.viewer_url = event.metadata.viewerUrl;
1243
+ } else if (
1244
+ event.step === 'filters' &&
1245
+ event.level === 'info' &&
1246
+ /Filter validation passed/i.test(event.message)
1247
+ ) {
1248
+ fields.last_completed_step = 'filters';
1249
+ } else if (
1250
+ event.step === 'download' &&
1251
+ event.level === 'info' &&
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) {
1264
+ await safeUpdateJobFields(jobId, fields);
1265
+ }
1266
+ }
1267
+
1268
+ async function safeUpdateJobFields(
1269
+ jobId: string,
1270
+ fields: Partial<TurnitinJob>,
1271
+ ): Promise<void> {
1272
+ try {
1273
+ await updateJobFields(jobId, fields);
1274
+ } catch (err) {
1275
+ logger.warn('safeUpdateJobFields: could not persist job progress', {
1276
+ jobId,
1277
+ fields: Object.keys(fields),
1278
+ error: err instanceof Error ? err.message : String(err),
1279
+ });
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * Safely persist the last completed step to the job row.
1285
+ * Logs a warning on failure (e.g. column not yet migrated or DB unreachable)
1286
+ * but never throws — step tracking must not crash the main job flow.
1287
+ */
1288
+ async function safeUpdateLastCompletedStep(
1289
+ jobId: string,
1290
+ step: string | null | undefined,
1291
+ ): Promise<void> {
1292
+ if (!step) return;
1293
+ try {
1294
+ const { supabase } = await import('../db/client');
1295
+ const { error } = await supabase
1296
+ .from('turnitin_jobs')
1297
+ .update({ last_completed_step: step, updated_at: new Date().toISOString() })
1298
+ .eq('id', jobId);
1299
+ if (error) {
1300
+ logger.warn('safeUpdateLastCompletedStep: could not persist step (column may not exist yet)', {
1301
+ jobId,
1302
+ step,
1303
+ error: error.message,
1304
+ });
1305
+ }
1306
+ } catch (err) {
1307
+ logger.warn('safeUpdateLastCompletedStep: unexpected error', {
1308
+ jobId,
1309
+ step,
1310
+ error: err instanceof Error ? err.message : String(err),
1311
+ });
1312
+ }
1313
+ }
tsconfig.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022", "DOM"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "moduleResolution": "node"
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }