From fea7fa8b03238057756c4394bf58e7b1dd2ccb71 Mon Sep 17 00:00:00 2001 From: Albin Jaldevik Date: Tue, 15 Jul 2025 17:56:05 +0200 Subject: [PATCH 001/211] init supabase --- .gitignore | 6 +- frontend/.env.example | 5 + frontend/package-lock.json | 386 +++++++++++++++++++++++++++++++++- frontend/package.json | 4 +- frontend/supabase/.gitignore | 8 + frontend/supabase/config.toml | 290 +++++++++++++++++++++++++ frontend/supabase/seed.sql | 0 7 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 frontend/supabase/.gitignore create mode 100644 frontend/supabase/config.toml create mode 100644 frontend/supabase/seed.sql diff --git a/.gitignore b/.gitignore index e8ae1b2..2505229 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,8 @@ temp/ .netlify # Local development -.local \ No newline at end of file +.local + +.cursor/ +.mcp.json +.claude diff --git a/frontend/.env.example b/frontend/.env.example index 597237d..73d9a91 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,11 +1,16 @@ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= + ELEVENLABS_API_KEY= ELEVENLABS_AGENT_ID= ELEVENLABS_AGENT_PHONE_ID= + OPENAI_API_KEY= GROQ_API_KEY= GITHUB_TOKEN= NEXT_PUBLIC_APP_URL= NEXT_PUBLIC_APP_ENV= + +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e600b1..9230db8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@supabase/supabase-js": "^2.51.0", "@tailwindcss/typography": "^0.5.16", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", @@ -42,11 +43,12 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", - "@types/node": "^20.19.4", + "@types/node": "^20.19.8", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.3.5", + "supabase": "^2.31.4", "ts-node": "^10.9.1", "tsx": "^4.20.3", "tw-animate-css": "^1.3.5", @@ -1207,6 +1209,19 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -2290,6 +2305,81 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.71.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.0.tgz", + "integrity": "sha512-OMYNbhGa1Cj4stalJq0VoHm5l7Sj/xY0j9CiYEQCikbQmtiDG3c27EIFA4OD+NxuoHTZmjaW8VJlS3SP+yasEA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.15.tgz", + "integrity": "sha512-HQKRnwAqdVqJW/P9TjKVK+/ETpW4yQ8tyDPPtRMKOH4Uh3vQD74vmj353CYs8+YwVBKubeUOOEpI9CT8mT4obw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "isows": "^1.0.7", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.51.0.tgz", + "integrity": "sha512-jG70XoNFcX3z0h/No0t1Aoc3zoHPtMQk5zaM5v3+sCJ/v5Z3qyoHYkGIg1JUycINPsuuAASZ4ZS43YO6H5wMoA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.0", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.15", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -2370,9 +2460,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.4.tgz", - "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", + "version": "20.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.8.tgz", + "integrity": "sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2388,6 +2478,12 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2408,6 +2504,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -3165,6 +3270,23 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3389,6 +3511,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3416,6 +3548,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -3538,6 +3680,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4499,6 +4651,40 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-blob/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4649,6 +4835,19 @@ "node": ">= 12.20" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -5582,6 +5781,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -5991,6 +6205,35 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/motion-dom": { "version": "12.22.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.22.0.tgz", @@ -6613,6 +6856,16 @@ "node": ">=0.10.0" } }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7144,6 +7397,16 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7313,6 +7576,16 @@ "pify": "^2.3.0" } }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8058,6 +8331,69 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supabase": { + "version": "2.31.4", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.31.4.tgz", + "integrity": "sha512-5OIhaXuJHq/rdu6yIozmD4kZa1//IzfQTmrSE74DgJOP4Nz6Y5ynMdaCCg8kTWv34MyOMn0GXrbjhObLdsyElA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^5.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.4.3" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, + "node_modules/supabase/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/supabase/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/supabase/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8186,6 +8522,24 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8900,6 +9254,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -8930,6 +9298,16 @@ "node": ">=6.0" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b606c65..f9e23e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@supabase/supabase-js": "^2.51.0", "@tailwindcss/typography": "^0.5.16", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", @@ -45,11 +46,12 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", - "@types/node": "^20.19.4", + "@types/node": "^20.19.8", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.3.5", + "supabase": "^2.31.4", "ts-node": "^10.9.1", "tsx": "^4.20.3", "tw-animate-css": "^1.3.5", diff --git a/frontend/supabase/.gitignore b/frontend/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/frontend/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/frontend/supabase/config.toml b/frontend/supabase/config.toml new file mode 100644 index 0000000..67daa6b --- /dev/null +++ b/frontend/supabase/config.toml @@ -0,0 +1,290 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "frontend" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/MY_FUNCTION_NAME/*.html" ] + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/frontend/supabase/seed.sql b/frontend/supabase/seed.sql new file mode 100644 index 0000000..e69de29 From 92d168164e7eba9bb27af7ef1bc618df61c5786b Mon Sep 17 00:00:00 2001 From: eddydavies Date: Tue, 15 Jul 2025 20:34:03 +0100 Subject: [PATCH 002/211] feat: enhance applicant submission process and validation - Update applicant creation to require at least one of CV file, LinkedIn profile, or GitHub URL. - Improve error handling and user feedback for missing data. - Make CV file optional in the submission form and adjust validation logic accordingly. - Refactor data processing to handle cases where multiple data sources are provided. - Update interfaces to reflect changes in applicant data structure. --- frontend/package.json | 3 +- frontend/src/app/api/applicants/route.ts | 56 +++++++++++-------- .../app/board/components/NewApplicantForm.tsx | 37 ++++++------ .../src/lib/contexts/ApplicantContext.tsx | 5 +- frontend/src/lib/interfaces/applicant.ts | 2 +- 5 files changed, 60 insertions(+), 43 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b606c65..f9222be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "test:cv": "npx tsx src/lib/simple_tests/test-cv.ts", - "test:evaluation": "npx tsx src/lib/simple_tests/test-evaluation.ts" + "test:evaluation": "npx tsx src/lib/simple_tests/test-evaluation.ts", + "batch-process": "npx tsx src/lib/simple_tests/batch-process.ts" }, "dependencies": { "@elevenlabs/elevenlabs-js": "^2.5.0", diff --git a/frontend/src/app/api/applicants/route.ts b/frontend/src/app/api/applicants/route.ts index 494b6bd..d8e2a15 100644 --- a/frontend/src/app/api/applicants/route.ts +++ b/frontend/src/app/api/applicants/route.ts @@ -42,9 +42,9 @@ export async function POST(request: NextRequest) { const linkedinFile = formData.get('linkedinFile') as File; const githubUrl = formData.get('githubUrl') as string; - if (!cvFile) { + if (!cvFile && !linkedinFile && !githubUrl) { return NextResponse.json( - { error: 'CV file is required', success: false }, + { error: 'At least one of CV file, LinkedIn profile, or GitHub URL is required', success: false }, { status: 400 } ); } @@ -65,9 +65,11 @@ export async function POST(request: NextRequest) { // Save initial record saveApplicant(applicant); - // Save CV file - const cvBuffer = Buffer.from(await cvFile.arrayBuffer()); - saveApplicantFile(applicantId, cvBuffer, 'cv.pdf'); + // Save CV file if provided + if (cvFile) { + const cvBuffer = Buffer.from(await cvFile.arrayBuffer()); + saveApplicantFile(applicantId, cvBuffer, 'cv.pdf'); + } // Save LinkedIn file if provided if (linkedinFile) { @@ -113,13 +115,18 @@ async function processApplicantAsync(applicantId: string, githubUrl?: string) { const processingPromises = []; - // Always process CV (required) - processingPromises.push( - processCvPdf(paths.cvPdf, true, cvTempSuffix).then(rawCvData => ({ - type: 'cv', - data: validateAndCleanCvData(rawCvData) - })) - ); + // Process CV if file exists + if (fs.existsSync(paths.cvPdf)) { + processingPromises.push( + processCvPdf(paths.cvPdf, true, cvTempSuffix).then(rawCvData => ({ + type: 'cv', + data: validateAndCleanCvData(rawCvData) + })).catch(error => { + console.warn(`CV processing failed for ${applicantId}:`, error); + return { type: 'cv', data: null, error: error.message }; + }) + ); + } // Process LinkedIn if file exists if (paths.linkedinFile && fs.existsSync(paths.linkedinFile)) { @@ -160,7 +167,6 @@ async function processApplicantAsync(applicantId: string, githubUrl?: string) { let cvData: CvData | null = null; let linkedinData: CvData | null = null; let githubData: GitHubData | null = null; - let hasErrors = false; for (const result of results) { if (result.status === 'fulfilled') { @@ -173,24 +179,28 @@ async function processApplicantAsync(applicantId: string, githubUrl?: string) { } } else { console.error(`Processing failed for ${applicantId}:`, result.reason); - if (result.reason?.message?.includes('CV')) { - hasErrors = true; // CV processing failure is critical - } } } - // CV processing is required for successful completion - if (!cvData || hasErrors) { - throw new Error('CV processing failed'); + // At least one data source is required for successful completion + if (!cvData && !linkedinData && !githubData) { + throw new Error('All data processing failed - no usable data sources'); } // Update applicant with all processed data at once - applicant.cvData = cvData; + applicant.cvData = cvData || undefined; applicant.linkedinData = linkedinData || undefined; applicant.githubData = githubData || undefined; - applicant.name = `${cvData.firstName} ${cvData.lastName}`.trim() || 'Unknown'; - applicant.email = cvData.email || ''; - applicant.role = cvData.jobTitle || ''; + + // Extract name and email from available data sources + const primaryData = cvData || linkedinData; + const githubName = githubData?.name || githubData?.username; + + applicant.name = primaryData + ? `${primaryData.firstName} ${primaryData.lastName}`.trim() || 'Unknown' + : githubName || 'Unknown'; + applicant.email = primaryData?.email || githubData?.email || ''; + applicant.role = primaryData?.jobTitle || ''; applicant.status = 'analyzing'; // Save intermediate state before analysis diff --git a/frontend/src/app/board/components/NewApplicantForm.tsx b/frontend/src/app/board/components/NewApplicantForm.tsx index 549e99f..6ad0f62 100644 --- a/frontend/src/app/board/components/NewApplicantForm.tsx +++ b/frontend/src/app/board/components/NewApplicantForm.tsx @@ -138,8 +138,8 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { }; const handleCreateCandidate = async () => { - if (!cvFile) { - alert('Please select a CV file'); + if (!linkedinFile && !githubUrl.trim()) { + alert('Please provide either a LinkedIn profile or GitHub URL'); return; } @@ -147,7 +147,7 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { try { const applicantId = await createApplicant({ - cvFile, + cvFile: cvFile || undefined, linkedinFile: linkedinFile || undefined, githubUrl: githubUrl.trim() || undefined }); @@ -169,27 +169,16 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { } }; - const isFormValid = cvFile && !isCreating && !isLoading; + const isFormValid = (linkedinFile || githubUrl.trim()) && !isCreating && !isLoading; return (

New Applicant

-

Upload candidate information to begin analysis.

+

Provide LinkedIn profile or GitHub URL to begin analysis. CV is optional.

- {/* CV Upload */} - - {/* LinkedIn Profile Upload */} {/* GitHub URL Input */}
- +
+ {/* CV Upload (Optional) */} + + {/* Submit Button */} + + +
+ +

+ If this problem persists, please contact our support team. +

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/auth/callback/route.ts b/frontend/src/app/auth/callback/route.ts new file mode 100644 index 0000000..de752e6 --- /dev/null +++ b/frontend/src/app/auth/callback/route.ts @@ -0,0 +1,29 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + // if "next" is in param, use it as the redirect URL + const next = searchParams.get('next') ?? '/board' + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (!error) { + const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer + const isLocalEnv = process.env.NODE_ENV === 'development' + if (isLocalEnv) { + // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host + return NextResponse.redirect(`${origin}${next}`) + } else if (forwardedHost) { + return NextResponse.redirect(`https://${forwardedHost}${next}`) + } else { + return NextResponse.redirect(`${origin}${next}`) + } + } + } + + // return the user to an error page with instructions + return NextResponse.redirect(`${origin}/auth/auth-code-error`) +} \ No newline at end of file diff --git a/frontend/src/app/auth/logout/actions.ts b/frontend/src/app/auth/logout/actions.ts new file mode 100644 index 0000000..4bf4e5e --- /dev/null +++ b/frontend/src/app/auth/logout/actions.ts @@ -0,0 +1,19 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' + +export async function logout() { + const supabase = await createClient() + + const { error } = await supabase.auth.signOut() + + if (error) { + console.error('Logout error:', error) + return { error: error.message } + } + + revalidatePath('/', 'layout') + redirect('/') +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ce84877..29a8107 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import ConditionalNavbar from "../components/ConditionalNavbar"; import { ApplicantProvider } from "../lib/contexts/ApplicantContext"; +import { AuthProvider } from "../lib/contexts/AuthContext"; import { GoogleTagManager } from '@next/third-parties/google' const geistSans = Geist({ @@ -32,10 +33,12 @@ export default function RootLayout({ - - - {children} - + + + + {children} + + ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..b1f578a --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card } from '@/components/ui/card'; +import Link from 'next/link'; + +export default function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isSignUp, setIsSignUp] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const { signIn, signUp, signInWithGoogle } = useAuth(); + const router = useRouter(); + + const handleEmailAuth = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const { error } = isSignUp + ? await signUp(email, password) + : await signIn(email, password); + + if (error) { + setError(error.message); + } else { + router.push('/board'); + } + } catch { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + const handleGoogleAuth = async () => { + setLoading(true); + setError(null); + + try { + const { error } = await signInWithGoogle(); + + if (error) { + setError(error.message); + } + // Redirect will be handled by the OAuth flow + } catch { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+ Unmask +
+

+ {isSignUp ? 'Create Account' : 'Welcome Back'} +

+

+ {isSignUp + ? 'Sign up to access the Unmask platform' + : 'Sign in to your account to continue' + } +

+
+ +
+ + +
+
+ +
+
+ Or continue with email +
+
+ +
+
+ setEmail(e.target.value)} + required + disabled={loading} + /> +
+
+ setPassword(e.target.value)} + required + disabled={loading} + minLength={6} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ +
+

+ {isSignUp ? 'Already have an account?' : "Don't have an account?"} + {' '} + +

+
+ +
+ + ← Back to homepage + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/BoardSidebar.tsx b/frontend/src/components/BoardSidebar.tsx index 8013387..6d4bc30 100644 --- a/frontend/src/components/BoardSidebar.tsx +++ b/frontend/src/components/BoardSidebar.tsx @@ -3,8 +3,10 @@ import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState, useCallback, memo, useMemo, useEffect } from 'react'; -import { LayoutDashboard, Users, Plus, ChevronDown, Settings, Check, Search } from 'lucide-react'; +import { LayoutDashboard, Users, Plus, ChevronDown, Settings, Check, Search, LogOut } from 'lucide-react'; import { useApplicants } from '../lib/contexts/ApplicantContext'; +import { useAuth } from '../lib/contexts/AuthContext'; +import { Button } from './ui/button'; const ANIMATION_DURATION = { SIDEBAR: 500, @@ -64,6 +66,7 @@ const BoardSidebarComponent = ({ isCollapsed, onToggle }: BoardSidebarProps) => const router = useRouter(); const searchParams = useSearchParams(); const { applicants } = useApplicants(); + const { user, signOut } = useAuth(); const [applicantsDropdownOpen, setApplicantsDropdownOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchFocused, setSearchFocused] = useState(false); @@ -360,15 +363,31 @@ const BoardSidebarComponent = ({ isCollapsed, onToggle }: BoardSidebarProps) =>
-
-
- D -
-
- - David - +
+
+
+ + {user?.email?.charAt(0).toUpperCase() || 'U'} + +
+
+ + {user?.email || 'User'} + +
+ + {!isCollapsed && ( + + )}
diff --git a/frontend/src/components/NerdBusterHeaderLogo.tsx b/frontend/src/components/NerdBusterHeaderLogo.tsx index f75601e..27d489d 100644 --- a/frontend/src/components/NerdBusterHeaderLogo.tsx +++ b/frontend/src/components/NerdBusterHeaderLogo.tsx @@ -2,7 +2,9 @@ import Link from "next/link"; import { useState, useEffect } from "react"; -import { Menu, X } from "lucide-react"; +import { Menu, X, User, LogOut } from "lucide-react"; +import { useAuth } from "@/lib/contexts/AuthContext"; +import { Button } from "@/components/ui/button"; const ArrowIcon = () => ( { const handleScroll = () => { @@ -72,6 +75,10 @@ export default function Navbar({ onDemoOpen, onWaitlistOpen }: HeaderProps = {}) } }; + const handleSignOut = async () => { + await signOut(); + }; + return (
{/* Desktop CTA buttons */} -
- +
+ {loading ? ( +
Loading...
+ ) : user ? ( + <> +
+ + {user.email} +
+ + Dashboard + + + + + ) : ( + <> + + Sign In + + + + )}
@@ -174,16 +216,55 @@ export default function Navbar({ onDemoOpen, onWaitlistOpen }: HeaderProps = {})
- + {loading ? ( +
Loading...
+ ) : user ? ( +
+
+ + {user.email} +
+ setIsMenuOpen(false)} + className="w-full flex items-center justify-center gap-x-1 px-6 py-3 text-base font-semibold rounded-full text-white bg-black hover:bg-pink-500 hover:shadow-[0_0_20px_rgba(255,105,180,0.7)] transition-all duration-300" + > + Dashboard + + + +
+ ) : ( +
+ setIsMenuOpen(false)} + className="w-full flex items-center justify-center px-6 py-3 text-base font-semibold rounded-full border border-gray-300 text-gray-700 hover:bg-gray-50 transition-all duration-300" + > + Sign In + + +
+ )}
diff --git a/frontend/src/lib/contexts/AuthContext.tsx b/frontend/src/lib/contexts/AuthContext.tsx new file mode 100644 index 0000000..000b174 --- /dev/null +++ b/frontend/src/lib/contexts/AuthContext.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { User, Session, AuthError } from '@supabase/supabase-js'; +import { createClient } from '@/lib/supabase/client'; + +interface AuthContextType { + user: User | null; + session: Session | null; + loading: boolean; + signIn: (email: string, password: string) => Promise<{ error: AuthError | null }>; + signUp: (email: string, password: string) => Promise<{ error: AuthError | null }>; + signInWithGoogle: () => Promise<{ error: AuthError | null }>; + signOut: () => Promise<{ error: AuthError | null }>; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const supabase = createClient(); + + useEffect(() => { + // Get initial session + const getSession = async () => { + const { data: { session } } = await supabase.auth.getSession(); + setSession(session); + setUser(session?.user ?? null); + setLoading(false); + }; + + getSession(); + + // Listen for auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (_, session) => { + setSession(session); + setUser(session?.user ?? null); + setLoading(false); + } + ); + + return () => subscription.unsubscribe(); + }, [supabase.auth]); + + const signIn = async (email: string, password: string) => { + setLoading(true); + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + setLoading(false); + return { error }; + }; + + const signUp = async (email: string, password: string) => { + setLoading(true); + const { error } = await supabase.auth.signUp({ + email, + password, + }); + setLoading(false); + return { error }; + }; + + const signInWithGoogle = async () => { + setLoading(true); + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }); + setLoading(false); + return { error }; + }; + + const signOut = async () => { + setLoading(true); + const { error } = await supabase.auth.signOut(); + if (!error) { + // Redirect to home page after successful logout + window.location.href = '/'; + } + setLoading(false); + return { error }; + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/lib/supabase/client.ts b/frontend/src/lib/supabase/client.ts new file mode 100644 index 0000000..78ff395 --- /dev/null +++ b/frontend/src/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} \ No newline at end of file diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts new file mode 100644 index 0000000..87a6ba7 --- /dev/null +++ b/frontend/src/lib/supabase/server.ts @@ -0,0 +1,29 @@ +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' + +export async function createClient() { + const cookieStore = await cookies() + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + } + ) +} \ No newline at end of file diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..9cfce59 --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,80 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // Do not run code between createServerClient and + // supabase.auth.getUser(). A simple mistake could make it very hard to debug + // issues with users being randomly logged out. + + // IMPORTANT: DO NOT REMOVE auth.getUser() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if ( + !user && + !request.nextUrl.pathname.startsWith('/login') && + !request.nextUrl.pathname.startsWith('/auth') && + request.nextUrl.pathname.startsWith('/board') + ) { + // no user, potentially respond by redirecting the user to the login page + const url = request.nextUrl.clone() + url.pathname = '/login' + return NextResponse.redirect(url) + } + + // IMPORTANT: You *must* return the supabaseResponse object as it is. + // If you're creating a new response object with NextResponse.next() make sure to: + // 1. Pass the request in it, like so: + // const myNewResponse = NextResponse.next({ request }) + // 2. Copy over the cookies, like so: + // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) + // 3. Change the myNewResponse object to fit your needs, but avoid changing + // the cookies! + // 4. Finally: + // return myNewResponse + // If this is not done, you may be causing the browser and server to go out + // of sync and terminate the user's session prematurely! + + return supabaseResponse +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public files (public folder) + */ + '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +}; \ No newline at end of file From c9e1b31f2a959aab79f3d21bced3fcd65967aec0 Mon Sep 17 00:00:00 2001 From: Albin Jaldevik Date: Tue, 15 Jul 2025 23:11:07 +0200 Subject: [PATCH 007/211] remove google auth --- frontend/src/app/login/page.tsx | 61 ++--------------------- frontend/src/lib/contexts/AuthContext.tsx | 13 ----- 2 files changed, 3 insertions(+), 71 deletions(-) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index b1f578a..31f1688 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -15,7 +15,7 @@ export default function LoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const { signIn, signUp, signInWithGoogle } = useAuth(); + const { signIn, signUp } = useAuth(); const router = useRouter(); const handleEmailAuth = async (e: React.FormEvent) => { @@ -40,23 +40,6 @@ export default function LoginPage() { } }; - const handleGoogleAuth = async () => { - setLoading(true); - setError(null); - - try { - const { error } = await signInWithGoogle(); - - if (error) { - setError(error.message); - } - // Redirect will be handled by the OAuth flow - } catch { - setError('An unexpected error occurred'); - } finally { - setLoading(false); - } - }; return (
@@ -76,44 +59,7 @@ export default function LoginPage() {

-
- - -
-
- -
-
- Or continue with email -
-
- -
+
{loading ? 'Loading...' : (isSignUp ? 'Create Account' : 'Sign In')} -

diff --git a/frontend/src/lib/contexts/AuthContext.tsx b/frontend/src/lib/contexts/AuthContext.tsx index 000b174..67fe291 100644 --- a/frontend/src/lib/contexts/AuthContext.tsx +++ b/frontend/src/lib/contexts/AuthContext.tsx @@ -10,7 +10,6 @@ interface AuthContextType { loading: boolean; signIn: (email: string, password: string) => Promise<{ error: AuthError | null }>; signUp: (email: string, password: string) => Promise<{ error: AuthError | null }>; - signInWithGoogle: () => Promise<{ error: AuthError | null }>; signOut: () => Promise<{ error: AuthError | null }>; } @@ -65,17 +64,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { error }; }; - const signInWithGoogle = async () => { - setLoading(true); - const { error } = await supabase.auth.signInWithOAuth({ - provider: 'google', - options: { - redirectTo: `${window.location.origin}/auth/callback`, - }, - }); - setLoading(false); - return { error }; - }; const signOut = async () => { setLoading(true); @@ -95,7 +83,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, signIn, signUp, - signInWithGoogle, signOut, }}> {children} From e20db6466aea80d1e36f96cdf37f70ecb1226aeb Mon Sep 17 00:00:00 2001 From: eddydavies Date: Tue, 15 Jul 2025 23:10:11 +0100 Subject: [PATCH 008/211] feat: update applicant processing to support LinkedIn URLs and enhance CSV format - Modify CSV format requirements to include `cv` instead of `github` as an optional field. - Update applicant submission logic to accept LinkedIn profile URLs alongside CV files. - Refactor applicant data processing to handle LinkedIn URLs, including new function for API integration. - Enhance validation to ensure at least one of `linkedin` or `cv` is provided for each applicant. - Improve user interface for applicant form to allow input of LinkedIn URLs or file uploads. --- frontend/BATCH_PROCESSING.md | 16 +- frontend/example-applicants.csv | 6 +- frontend/src/app/api/applicants/route.ts | 34 +- .../app/board/components/ApplicantSidebar.tsx | 2 +- .../app/board/components/NewApplicantForm.tsx | 69 +++- .../src/lib/contexts/ApplicantContext.tsx | 5 +- frontend/src/lib/cv.ts | 105 ++++++ frontend/src/lib/interfaces/applicant.ts | 10 +- .../src/lib/simple_tests/batch-process.ts | 63 +++- .../lib/simple_tests/hackathon-analysis.ts | 344 ++++++++++++++++++ mock_data/UM x Mtal Hack.csv | 133 +++++++ 11 files changed, 724 insertions(+), 63 deletions(-) create mode 100644 frontend/src/lib/simple_tests/hackathon-analysis.ts create mode 100644 mock_data/UM x Mtal Hack.csv diff --git a/frontend/BATCH_PROCESSING.md b/frontend/BATCH_PROCESSING.md index 34d2f8f..81d1679 100644 --- a/frontend/BATCH_PROCESSING.md +++ b/frontend/BATCH_PROCESSING.md @@ -10,28 +10,28 @@ npm run batch-process ## CSV Format -The CSV file should have the following columns (all optional except at least one of `linkedin` or `github`): +The CSV file should have the following columns (all optional except at least one of `linkedin` or `cv`): - `name`: Applicant's full name (optional - will be extracted from other sources) - `email`: Applicant's email address (optional - will be extracted from other sources) - `linkedin`: LinkedIn profile URL or path to downloaded profile file (PDF, HTML, or TXT) -- `github`: GitHub profile URL (e.g., https://github.com/username) -- `cv`: Path to CV file (PDF, DOC, or DOCX) - optional +- `github`: GitHub profile URL (e.g., https://github.com/username) - optional +- `cv`: Path to CV file (PDF, DOC, or DOCX) ### Example CSV: ```csv name,email,linkedin,github,cv -John Doe,john@example.com,https://linkedin.com/in/johndoe,https://github.com/johndoe,/path/to/johns-cv.pdf +John Doe,john@example.com,https://linkedin.com/in/johndoe,https://github.com/johndoe, Jane Smith,jane@example.com,jane-linkedin-profile.pdf,https://github.com/janesmith, -Bob Johnson,,https://linkedin.com/in/bobjohnson,https://github.com/bobjohnson,/path/to/bobs-resume.pdf -Alice Brown,alice@example.com,alice-profile.html,https://github.com/alicebrown, -Charlie Wilson,,https://linkedin.com/in/charliewilson,https://github.com/charliewilson, +Bob Johnson,,https://linkedin.com/in/bobjohnson,https://github.com/bobjohnson, +Alice Brown,alice@example.com,,,/path/to/alice-cv.pdf +Charlie Wilson,,https://linkedin.com/in/charliewilson,, ``` ## Requirements -- At least one of `linkedin` or `github` must be provided for each row +- At least one of `linkedin` or `cv` must be provided for each row - LinkedIn can be: - A URL to the LinkedIn profile - A file path to a downloaded LinkedIn profile (PDF, HTML, or TXT) diff --git a/frontend/example-applicants.csv b/frontend/example-applicants.csv index 0cd218f..5116ef6 100644 --- a/frontend/example-applicants.csv +++ b/frontend/example-applicants.csv @@ -1,6 +1,6 @@ name,email,linkedin,github,cv John Doe,john@example.com,https://linkedin.com/in/johndoe,https://github.com/johndoe, -Jane Smith,jane@example.com,,https://github.com/janesmith, +Jane Smith,jane@example.com,jane-linkedin-profile.pdf,https://github.com/janesmith, Bob Johnson,,https://linkedin.com/in/bobjohnson,https://github.com/bobjohnson, -Alice Brown,alice@example.com,,https://github.com/alicebrown, -Charlie Wilson,,https://linkedin.com/in/charliewilson,https://github.com/charliewilson, \ No newline at end of file +Alice Brown,alice@example.com,,,/path/to/alice-cv.pdf +Charlie Wilson,,https://linkedin.com/in/charliewilson,, \ No newline at end of file diff --git a/frontend/src/app/api/applicants/route.ts b/frontend/src/app/api/applicants/route.ts index fcb8f89..9c9a9b5 100644 --- a/frontend/src/app/api/applicants/route.ts +++ b/frontend/src/app/api/applicants/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Applicant } from '@/lib/interfaces/applicant'; import { CvData } from '@/lib/interfaces/cv'; import { GitHubData } from '@/lib/interfaces/github'; -import { processCvPdf, validateAndCleanCvData, processLinkedInPdf } from '@/lib/cv'; +import { processCvPdf, validateAndCleanCvData, processLinkedInPdf, processLinkedInUrl } from '@/lib/cv'; import { processGitHubAccount } from '@/lib/github'; import { analyzeApplicant } from '@/lib/analysis'; import * as fs from 'fs'; @@ -39,12 +39,12 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const cvFile = formData.get('cvFile') as File; - const linkedinFile = formData.get('linkedinFile') as File; + const linkedinUrl = formData.get('linkedinUrl') as string; const githubUrl = formData.get('githubUrl') as string; - if (!cvFile && !linkedinFile) { + if (!cvFile && !linkedinUrl) { return NextResponse.json( - { error: 'Either CV file or LinkedIn profile is required', success: false }, + { error: 'Either CV file or LinkedIn profile URL is required', success: false }, { status: 400 } ); } @@ -58,8 +58,9 @@ export async function POST(request: NextRequest) { email: '', status: 'uploading', createdAt: new Date().toISOString(), - originalFileName: cvFile.name, - originalGithubUrl: githubUrl + originalFileName: cvFile?.name, + originalGithubUrl: githubUrl, + originalLinkedinUrl: linkedinUrl }; // Save initial record @@ -71,15 +72,9 @@ export async function POST(request: NextRequest) { saveApplicantFile(applicantId, cvBuffer, 'cv.pdf'); } - // Save LinkedIn file if provided - if (linkedinFile) { - const linkedinBuffer = Buffer.from(await linkedinFile.arrayBuffer()); - const linkedinExt = linkedinFile.name.endsWith('.html') ? 'html' : 'pdf'; - saveApplicantFile(applicantId, linkedinBuffer, `linkedin.${linkedinExt}`); - } // Process asynchronously - processApplicantAsync(applicantId, githubUrl); + processApplicantAsync(applicantId, githubUrl, linkedinUrl); return NextResponse.json({ applicant, @@ -95,7 +90,7 @@ export async function POST(request: NextRequest) { } } -async function processApplicantAsync(applicantId: string, githubUrl?: string) { +async function processApplicantAsync(applicantId: string, githubUrl?: string, linkedinUrl?: string) { try { const paths = getApplicantPaths(applicantId); const applicant = loadApplicant(applicantId); @@ -128,14 +123,15 @@ async function processApplicantAsync(applicantId: string, githubUrl?: string) { ); } - // Process LinkedIn if file exists - if (paths.linkedinFile && fs.existsSync(paths.linkedinFile)) { + + // Process LinkedIn URL if provided + if (linkedinUrl) { processingPromises.push( - processLinkedInPdf(paths.linkedinFile, true, linkedinTempSuffix).then(rawLinkedinData => ({ + processLinkedInUrl(linkedinUrl).then(linkedinData => ({ type: 'linkedin', - data: validateAndCleanCvData(rawLinkedinData, 'linkedin') + data: linkedinData })).catch(error => { - console.warn(`LinkedIn processing failed for ${applicantId}:`, error); + console.warn(`LinkedIn URL processing failed for ${applicantId}:`, error); return { type: 'linkedin', data: null, error: error.message }; }) ); diff --git a/frontend/src/app/board/components/ApplicantSidebar.tsx b/frontend/src/app/board/components/ApplicantSidebar.tsx index 62faa91..7997d60 100644 --- a/frontend/src/app/board/components/ApplicantSidebar.tsx +++ b/frontend/src/app/board/components/ApplicantSidebar.tsx @@ -57,7 +57,7 @@ export default function ApplicantSidebar({ {applicant.status === 'processing' && (

)} - {applicant.status === 'analyzing' && ( + {applicant.status === 'analyzing' && (applicant.cvData || applicant.linkedinData || applicant.githubData) && (
)} {applicant.status === 'uploading' && ( diff --git a/frontend/src/app/board/components/NewApplicantForm.tsx b/frontend/src/app/board/components/NewApplicantForm.tsx index d98b25b..ab1658b 100644 --- a/frontend/src/app/board/components/NewApplicantForm.tsx +++ b/frontend/src/app/board/components/NewApplicantForm.tsx @@ -128,18 +128,22 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { // Form state const [cvFile, setCvFile] = useState(null); const [linkedinFile, setLinkedinFile] = useState(null); + const [linkedinUrl, setLinkedinUrl] = useState(''); const [githubUrl, setGithubUrl] = useState(''); const [isCreating, setIsCreating] = useState(false); + const [showLinkedinOptions, setShowLinkedinOptions] = useState(false); const resetForm = () => { setCvFile(null); setLinkedinFile(null); + setLinkedinUrl(''); setGithubUrl(''); + setShowLinkedinOptions(false); }; const handleCreateCandidate = async () => { - if (!linkedinFile && !cvFile) { - alert('Please provide either a LinkedIn profile or CV file'); + if (!linkedinUrl.trim() && !cvFile) { + alert('Please provide either a LinkedIn profile URL or CV file'); return; } @@ -148,7 +152,7 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { try { const applicantId = await createApplicant({ cvFile: cvFile || undefined, - linkedinFile: linkedinFile || undefined, + linkedinUrl: linkedinUrl.trim() || undefined, githubUrl: githubUrl.trim() || undefined }); @@ -169,36 +173,61 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { } }; - const isFormValid = (linkedinFile || cvFile) && !isCreating && !isLoading; + const isFormValid = (linkedinUrl.trim() || cvFile) && !isCreating && !isLoading; return (

New Applicant

-

Provide LinkedIn profile or CV file to begin analysis. GitHub URL is optional.

+

Provide LinkedIn profile URL or CV file to begin analysis. GitHub URL is optional.

- {/* LinkedIn Profile Upload */} - + {/* LinkedIn Profile Section */} +
+
+ + +
+ + {!showLinkedinOptions ? ( + setLinkedinUrl(e.target.value)} + placeholder="https://linkedin.com/in/username" + disabled={isCreating} + className="w-full px-4 py-3 border border-zinc-200 rounded-lg focus:ring-2 focus:ring-zinc-900 focus:border-transparent transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-900 placeholder-zinc-400" + /> + ) : ( + + )} +
{/* CV Upload */} {/* GitHub URL Input (Optional) */} @@ -227,7 +256,9 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { : 'bg-zinc-200 text-zinc-500 cursor-not-allowed' }`} > - {isCreating ? 'Creating...' : 'Unmask'} + {isCreating ? + (linkedinUrl.trim() || linkedinFile ? 'LinkedIn Analysis...' : 'CV Analysis...') + : 'Unmask'}
diff --git a/frontend/src/lib/contexts/ApplicantContext.tsx b/frontend/src/lib/contexts/ApplicantContext.tsx index 5f4de28..f0702b3 100644 --- a/frontend/src/lib/contexts/ApplicantContext.tsx +++ b/frontend/src/lib/contexts/ApplicantContext.tsx @@ -81,8 +81,9 @@ export function ApplicantProvider({ children }: { children: ReactNode }) { formData.append('cvFile', request.cvFile); } - if (request.linkedinFile) { - formData.append('linkedinFile', request.linkedinFile); + + if (request.linkedinUrl) { + formData.append('linkedinUrl', request.linkedinUrl); } if (request.githubUrl) { diff --git a/frontend/src/lib/cv.ts b/frontend/src/lib/cv.ts index 80451a7..4cb6cf0 100644 --- a/frontend/src/lib/cv.ts +++ b/frontend/src/lib/cv.ts @@ -977,3 +977,108 @@ function parseLinkedInMonth(dateStr: string): number | undefined { return undefined; } + +/** + * Process LinkedIn URL using BrightData API + * @param linkedinUrl - LinkedIn profile URL + * @returns ProfileData from LinkedIn API + */ +export async function processLinkedInUrl(linkedinUrl: string): Promise { + const apiKey = process.env.BRIGHTDATA_API_KEY; + const datasetId = process.env.BRIGHTDATA_DATASET_ID || 'gd_l1viktl72bvl7bjuj0'; + + if (!apiKey) { + throw new Error('BRIGHTDATA_API_KEY environment variable is not set'); + } + + try { + const response = await fetch('https://api.brightdata.com/datasets/v3/trigger', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([{ url: linkedinUrl }]), + // Add query parameters + }); + + if (!response.ok) { + throw new Error(`LinkedIn API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // The API returns a snapshot_id, we need to poll for results + const snapshotId = data.snapshot_id; + + if (!snapshotId) { + throw new Error('No snapshot_id returned from LinkedIn API'); + } + + // Poll for results + const results = await pollForLinkedInResults(snapshotId, apiKey); + + if (!results || results.length === 0) { + throw new Error('No LinkedIn data returned from API'); + } + + // Convert the first result to our ProfileData format + return convertLinkedInApiToProfileData(results[0]); + + } catch (error) { + console.error('Error processing LinkedIn URL:', error); + throw new Error(`Failed to process LinkedIn URL: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Poll for LinkedIn API results + * @param snapshotId - Snapshot ID from initial API call + * @param apiKey - API key for authentication + * @returns LinkedIn API results + */ +async function pollForLinkedInResults(snapshotId: string, apiKey: string): Promise { + const maxAttempts = 30; // 5 minutes with 10-second intervals + const pollInterval = 10000; // 10 seconds + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await fetch(`https://api.brightdata.com/datasets/v3/snapshot/${snapshotId}`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Polling failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Check if the snapshot is ready + if (data.status === 'ready' && data.data && data.data.length > 0) { + return data.data; + } + + // If not ready, wait and try again + if (data.status === 'running') { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } + + // If failed or other status + if (data.status === 'failed') { + throw new Error(`LinkedIn API processing failed: ${data.error || 'Unknown error'}`); + } + + } catch (error) { + if (attempt === maxAttempts - 1) { + throw error; + } + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + throw new Error('LinkedIn API polling timed out'); +} diff --git a/frontend/src/lib/interfaces/applicant.ts b/frontend/src/lib/interfaces/applicant.ts index b838d60..3593d04 100644 --- a/frontend/src/lib/interfaces/applicant.ts +++ b/frontend/src/lib/interfaces/applicant.ts @@ -13,6 +13,7 @@ export interface Applicant { createdAt: string; originalFileName?: string; originalGithubUrl?: string; + originalLinkedinUrl?: string; score?: number; // For compatibility with board page role?: string; // Job title from CV @@ -24,10 +25,17 @@ export interface Applicant { github?: GitHubAnalysis; }; crossReferenceAnalysis?: CrossReferenceAnalysis; + + // Hackathon-specific data + hackathonData?: { + teamName?: string; + problemsInterested?: string; + hasTeam?: boolean; + }; } export interface CreateApplicantRequest { cvFile?: File; - linkedinFile?: File; + linkedinUrl?: string; githubUrl?: string; } diff --git a/frontend/src/lib/simple_tests/batch-process.ts b/frontend/src/lib/simple_tests/batch-process.ts index 4eed8cd..83033da 100644 --- a/frontend/src/lib/simple_tests/batch-process.ts +++ b/frontend/src/lib/simple_tests/batch-process.ts @@ -12,6 +12,9 @@ interface CsvRow { linkedin?: string; github?: string; cv?: string; + team_name?: string; + problems_interested?: string; + no_team?: string; } function parseCsv(content: string): CsvRow[] { @@ -33,14 +36,28 @@ function parseCsv(content: string): CsvRow[] { row.email = values[index]; break; case 'linkedin': + case 'what is your linkedin profile?': row.linkedin = values[index]; break; case 'github': + case 'github repo': row.github = values[index]; break; case 'cv': row.cv = values[index]; break; + case 'team name': + case 'team name - make sure your whole team signs up using the same team name!': + row.team_name = values[index]; + break; + case 'problems': + case 'what problems are you interested in solving?': + row.problems_interested = values[index]; + break; + case 'no team': + case 'i don\'t have a team': + row.no_team = values[index]; + break; } } }); @@ -65,8 +82,8 @@ async function processApplicantFromCsv(row: CsvRow): Promise { const applicantId = crypto.randomUUID(); // Validate required fields - if (!row.linkedin && !row.github) { - throw new Error('At least one of LinkedIn or GitHub is required'); + if (!row.linkedin && !row.cv) { + throw new Error('At least one of LinkedIn or CV is required'); } // Create initial applicant record @@ -77,7 +94,13 @@ async function processApplicantFromCsv(row: CsvRow): Promise { status: 'uploading', createdAt: new Date().toISOString(), originalFileName: row.cv ? path.basename(row.cv) : undefined, - originalGithubUrl: row.github + originalGithubUrl: row.github, + originalLinkedinUrl: (row.linkedin && row.linkedin.startsWith('http')) ? row.linkedin : undefined, + hackathonData: { + teamName: row.team_name, + problemsInterested: row.problems_interested, + hasTeam: row.no_team?.toLowerCase() === 'no' + } }; // Save initial record @@ -150,6 +173,20 @@ async function processApplicantData(applicantId: string, row: CsvRow): Promise ({ + type: 'linkedin', + data: linkedinData + })).catch(error => { + console.warn(`LinkedIn URL processing failed for ${applicantId}:`, error); + return { type: 'linkedin', data: null, error: error.message }; + }) + ); + } + // Process GitHub if URL is provided if (row.github && row.github.startsWith('http')) { console.log(` - Processing GitHub: ${row.github}`); @@ -192,9 +229,9 @@ async function processApplicantData(applicantId: string, row: CsvRow): Promise'); console.log(''); - console.log('CSV format:'); + console.log('Hackathon CSV format:'); + console.log('email,phone_number,Team Name,What is your LinkedIn profile?,Github Repo,What problems are you interested in solving?,I don\'t have a team'); + console.log(''); + console.log('Standard CSV format:'); console.log('name,email,linkedin,github,cv'); console.log('John Doe,john@example.com,https://linkedin.com/in/johndoe,https://github.com/johndoe,/path/to/cv.pdf'); - console.log('Jane Smith,,jane-smith-profile.pdf,https://github.com/janesmith,'); console.log(''); console.log('Notes:'); - console.log('- At least one of linkedin or github is required'); + console.log('- At least one of linkedin or cv is required'); console.log('- linkedin can be a URL or file path to downloaded profile'); - console.log('- cv can be a file path (optional)'); + console.log('- cv must be a file path (PDF, DOC, or DOCX)'); + console.log('- github is optional (provide URL if available)'); console.log('- name and email are optional (will be extracted from other sources)'); + console.log(''); + console.log('After processing, run hackathon analysis:'); + console.log('npx tsx hackathon-analysis.ts'); process.exit(1); } diff --git a/frontend/src/lib/simple_tests/hackathon-analysis.ts b/frontend/src/lib/simple_tests/hackathon-analysis.ts new file mode 100644 index 0000000..71662de --- /dev/null +++ b/frontend/src/lib/simple_tests/hackathon-analysis.ts @@ -0,0 +1,344 @@ +#!/usr/bin/env npx tsx + +import * as fs from 'fs'; +import type { Applicant } from '../interfaces/applicant'; + +interface TeamGroup { + teamName: string; + members: Applicant[]; + isComplete: boolean; // Has 3 members + averageCredibility: number; +} + +interface CandidateScore { + applicant: Applicant; + individualScore: number; + teamFitScore: number; + totalScore: number; + reasoning: string[]; +} + +interface HackathonSelection { + selectedTeams: TeamGroup[]; + selectedIndividuals: Applicant[]; + totalSelected: number; + rejectedApplicants: Applicant[]; +} + +/** + * Load all applicants and analyze for hackathon selection + */ +async function analyzeHackathonCandidates(): Promise { + const { loadAllApplicants } = await import('../fileStorage'); + const applicants = loadAllApplicants(); + + console.log(`\n📊 Analyzing ${applicants.length} hackathon candidates...`); + + const scores: CandidateScore[] = []; + + for (const applicant of applicants) { + const score = calculateCandidateScore(applicant); + scores.push(score); + } + + // Sort by total score (highest first) + scores.sort((a, b) => b.totalScore - a.totalScore); + + return scores; +} + +/** + * Calculate comprehensive score for a candidate + */ +function calculateCandidateScore(applicant: Applicant): CandidateScore { + const reasoning: string[] = []; + let individualScore = 0; + let teamFitScore = 0; + + // 1. Credibility Score (40% weight) + const credibilityScore = applicant.analysisResult?.credibilityScore || 0; + individualScore += credibilityScore * 0.4; + reasoning.push(`Credibility: ${credibilityScore}/100`); + + // 2. Technical Skills (30% weight) + let techScore = 0; + if (applicant.githubData) { + techScore += 25; // Has GitHub + if (applicant.githubData.repositories && applicant.githubData.repositories.length > 5) { + techScore += 15; // Active developer + } + if (applicant.githubData.languages && applicant.githubData.languages.length > 3) { + techScore += 10; // Diverse skills + } + } + if (applicant.cvData || applicant.linkedinData) { + const profileData = applicant.linkedinData || applicant.cvData; + if (profileData?.skills && profileData.skills.length > 5) { + techScore += 20; // Good skill set + } + if (profileData?.experience && profileData.experience.length > 0) { + techScore += 20; // Has experience + } + } + techScore = Math.min(techScore, 100); // Cap at 100 + individualScore += techScore * 0.3; + reasoning.push(`Technical Skills: ${techScore}/100`); + + // 3. Communication/Profile Quality (20% weight) + let profileScore = 0; + if (applicant.hackathonData?.problemsInterested) { + profileScore += 40; // Described interests + if (applicant.hackathonData.problemsInterested.length > 50) { + profileScore += 20; // Detailed description + } + } + if (applicant.linkedinData || applicant.cvData) { + profileScore += 20; // Has professional profile + } + if (applicant.email && applicant.email.includes('@')) { + profileScore += 20; // Valid contact + } + profileScore = Math.min(profileScore, 100); + individualScore += profileScore * 0.2; + reasoning.push(`Profile Quality: ${profileScore}/100`); + + // 4. Team Fit Score (10% weight) + if (applicant.hackathonData?.hasTeam === false) { + teamFitScore = 80; // Looking for team + reasoning.push('Available for team formation'); + } else if (applicant.hackathonData?.teamName) { + teamFitScore = 60; // Already has team + reasoning.push(`Part of team: ${applicant.hackathonData.teamName}`); + } else { + teamFitScore = 40; // Unclear team status + reasoning.push('Team status unclear'); + } + individualScore += teamFitScore * 0.1; + + const totalScore = Math.round(individualScore); + + return { + applicant, + individualScore: Math.round(individualScore * 0.9), // Individual component + teamFitScore, + totalScore, + reasoning + }; +} + +/** + * Group candidates by teams and identify complete teams + */ +function groupByTeams(scores: CandidateScore[]): { teams: TeamGroup[], individuals: CandidateScore[] } { + const teamMap = new Map(); + const individuals: CandidateScore[] = []; + + for (const score of scores) { + const teamName = score.applicant.hackathonData?.teamName; + + if (teamName && teamName.trim() && teamName !== '-' && teamName !== 'N/A') { + const normalizedTeamName = teamName.toLowerCase().trim(); + if (!teamMap.has(normalizedTeamName)) { + teamMap.set(normalizedTeamName, []); + } + teamMap.get(normalizedTeamName)!.push(score.applicant); + } else { + individuals.push(score); + } + } + + const teams: TeamGroup[] = []; + for (const [teamName, members] of teamMap.entries()) { + const avgCredibility = members.reduce((sum, m) => + sum + (m.analysisResult?.credibilityScore || 50), 0) / members.length; + + teams.push({ + teamName, + members, + isComplete: members.length >= 3, + averageCredibility: Math.round(avgCredibility) + }); + } + + // Sort teams by average credibility + teams.sort((a, b) => b.averageCredibility - a.averageCredibility); + + return { teams, individuals }; +} + +/** + * Select 42 candidates optimizing for team balance and quality + */ +function selectCandidates(scores: CandidateScore[]): HackathonSelection { + const { teams, individuals } = groupByTeams(scores); + + let selected = 0; + const selectedTeams: TeamGroup[] = []; + const selectedIndividuals: Applicant[] = []; + const rejectedApplicants: Applicant[] = []; + + console.log(`\n🎯 Selection Strategy:`); + console.log(` 📝 Found ${teams.length} teams`); + console.log(` 👤 Found ${individuals.length} individuals`); + + // 1. Select complete teams first (3 members each) + for (const team of teams) { + if (team.isComplete && selected + 3 <= 42) { + if (team.averageCredibility >= 60) { // Quality threshold + selectedTeams.push(team); + selected += 3; + console.log(` ✅ Selected team "${team.teamName}" (3 members, avg credibility: ${team.averageCredibility})`); + } else { + console.log(` ❌ Rejected team "${team.teamName}" (low credibility: ${team.averageCredibility})`); + rejectedApplicants.push(...team.members); + } + } else if (team.isComplete) { + console.log(` ⏭️ Skipped team "${team.teamName}" (would exceed 42 limit)`); + rejectedApplicants.push(...team.members); + } + } + + // 2. Select partial teams (2 members, need 1 more) + for (const team of teams) { + if (team.members.length === 2 && selected + 2 <= 42) { + if (team.averageCredibility >= 65) { // Higher threshold for incomplete teams + selectedIndividuals.push(...team.members); + selected += 2; + console.log(` ✅ Selected partial team "${team.teamName}" (2 members, avg credibility: ${team.averageCredibility})`); + } else { + rejectedApplicants.push(...team.members); + } + } else if (team.members.length === 2) { + rejectedApplicants.push(...team.members); + } + } + + // 3. Fill remaining spots with highest-scoring individuals + const remainingSlots = 42 - selected; + const topIndividuals = individuals + .filter(s => s.totalScore >= 60) // Quality threshold + .slice(0, remainingSlots); + + for (const individual of topIndividuals) { + selectedIndividuals.push(individual.applicant); + selected++; + console.log(` ✅ Selected individual "${individual.applicant.name}" (score: ${individual.totalScore})`); + } + + // 4. Add rejected individuals + for (const individual of individuals.slice(remainingSlots)) { + rejectedApplicants.push(individual.applicant); + } + + // Add remaining partial teams to rejected + for (const team of teams) { + if (team.members.length === 1) { + rejectedApplicants.push(...team.members); + } + } + + return { + selectedTeams, + selectedIndividuals, + totalSelected: selected, + rejectedApplicants + }; +} + +/** + * Export results to CSV for review + */ +function exportResults(selection: HackathonSelection, scores: CandidateScore[]): void { + const csvRows: string[] = [ + 'Name,Email,Team,Status,Credibility Score,Total Score,LinkedIn,GitHub,Problems Interested,Reasoning' + ]; + + // Add selected candidates + for (const team of selection.selectedTeams) { + for (const member of team.members) { + const score = scores.find(s => s.applicant.id === member.id); + csvRows.push([ + member.name, + member.email, + team.teamName, + 'ACCEPTED', + member.analysisResult?.credibilityScore || '', + score?.totalScore || '', + member.originalLinkedinUrl || '', + member.originalGithubUrl || '', + member.hackathonData?.problemsInterested || '', + score?.reasoning.join('; ') || '' + ].map(field => `"${field}"`).join(',')); + } + } + + for (const individual of selection.selectedIndividuals) { + const score = scores.find(s => s.applicant.id === individual.id); + csvRows.push([ + individual.name, + individual.email, + individual.hackathonData?.teamName || 'INDIVIDUAL', + 'ACCEPTED', + individual.analysisResult?.credibilityScore || '', + score?.totalScore || '', + individual.originalLinkedinUrl || '', + individual.originalGithubUrl || '', + individual.hackathonData?.problemsInterested || '', + score?.reasoning.join('; ') || '' + ].map(field => `"${field}"`).join(',')); + } + + // Add rejected candidates + for (const rejected of selection.rejectedApplicants) { + const score = scores.find(s => s.applicant.id === rejected.id); + csvRows.push([ + rejected.name, + rejected.email, + rejected.hackathonData?.teamName || '', + 'REJECTED', + rejected.analysisResult?.credibilityScore || '', + score?.totalScore || '', + rejected.originalLinkedinUrl || '', + rejected.originalGithubUrl || '', + rejected.hackathonData?.problemsInterested || '', + score?.reasoning.join('; ') || '' + ].map(field => `"${field}"`).join(',')); + } + + const csvContent = csvRows.join('\n'); + const filename = `hackathon_selection_${new Date().toISOString().split('T')[0]}.csv`; + fs.writeFileSync(filename, csvContent); + console.log(`\n📁 Results exported to: ${filename}`); +} + +/** + * Main function + */ +async function main() { + try { + const scores = await analyzeHackathonCandidates(); + const selection = selectCandidates(scores); + + console.log(`\n🎉 Final Selection Summary:`); + console.log(` ✅ Selected: ${selection.totalSelected}/42 candidates`); + console.log(` 👥 Complete teams: ${selection.selectedTeams.length}`); + console.log(` 👤 Individuals: ${selection.selectedIndividuals.length}`); + console.log(` ❌ Rejected: ${selection.rejectedApplicants.length}`); + + exportResults(selection, scores); + + if (selection.totalSelected < 42) { + console.log(`\n⚠️ Warning: Only selected ${selection.totalSelected}/42 candidates. Consider lowering quality thresholds.`); + } + + } catch (error) { + console.error('Error during hackathon analysis:', error); + process.exit(1); + } +} + +if (require.main === module) { + main().catch(console.error); +} + +export { analyzeHackathonCandidates, selectCandidates, exportResults }; \ No newline at end of file diff --git a/mock_data/UM x Mtal Hack.csv b/mock_data/UM x Mtal Hack.csv new file mode 100644 index 0000000..d84c43b --- /dev/null +++ b/mock_data/UM x Mtal Hack.csv @@ -0,0 +1,133 @@ +email,phone_number,Team Name - Make sure your whole team signs up using the same team name!,What is your LinkedIn profile?,Github Repo,What problems are you interested in solving?,Dietary Preferences? (Intolerances - please make sure to inform us),I don't have a team,Notes,Status +sruthi.kuriakose99@gmail.com,+447424471914,-,https://linkedin.com/in/sruthikuriakose,Sruthi-sk,"Building automated Interpretability Agents, science-based problems",None,Yes,, +charles.cheesman1@gmail.com,+447874943523,(Not participating),https://linkedin.com/in/charliecheesman,https://github.com/Ches-ctrl/,N/A,None,Yes,, +bilal.saleem700@gmail.com,+447492053996,100Apps,https://linkedin.com/in/bilal-saleem-0379b0101,https://github.com/soongenwong/100apps,Video gen,Vegetarian,No,,Accepted +soong.wong23@imperial.ac.uk,+447917989810,100Apps,https://linkedin.com/in/soongenwong,https://github.com/soongenwong/100apps,video generation mobile apps. ,None,No,,Accepted +zyc24@ic.ac.uk,+447386808376,100apps,https://www.linkedin.com/in/chan-zhuo-yang-a164ba1b2/,https://github.com/zhuoyang125/,ai for automated content generation problems,None,No,,Accepted +mhmd.tanveer@gmail.com,+447305334511,3 in 1,https://linkedin.com/in/mhmdtanveer,http://www.na.com,"Enterprise Core - Engineering, Finance, Ops, Marketing, Legal",None,Yes,, +frontieraisummit@gmail.com,+447985334063,Agentz,https://linkedin.com/in/arifa-khan,https://github.com/agentzeta,complex automation workflows,Vegetarian,No,, +ana@amata.world,+447479898909,Amata World,https://linkedin.com/in/ana-domina,https://github.com/ameclair,"currently I'm very interested in AI likeness protection due to having a personal experience on being scanned on green screen on multiple film sets, I won't to be sure that the digital likeness won't be misused with GenAI - fight AI deepfakes/misuse with AI",Pescetarian,No,, +bryan@amata.world,+447789162128,Amata World,https://linkedin.com/in/bryan-mh-yap,https://github.com/yggie,,None,No,, +cathalmfpp@gmail.com,+447466002286,An Bradán Feasa,https://linkedin.com/in/cathal-o-shea,https://github.com/CathalOS,Helping people tell stories with AI - building on my app https://story.bradan.ai,None,Yes,, +tomasroma64@gmail.com,+447843195668,Cat's Clutter,https://linkedin.com/in/tomas-maillo,https://github.com/tomasmaillo,Improving education with AI! Loads can be done to make amazing experiences for students,None,No,, +arjun.naha@gmail.com,+447864538205,Cat’s Clutter,https://www.linkedin.com/in/arjunnaha/,https://github.com/arjunnaha,Real world challenges that affect people across the country,None,No,, +caterina.mammola20@gmail.com,+447914258060,Cat’s Clutter,https://linkedin.com/in/caterina-m,https://github.com/Cat2005,"Medical AI, education, fitness, women’s health + wellness, many more!",None,No,, +akinphil2005@gmail.com,+447403213054,Clifton Coders,https://www.linkedin.com/in/akin-ibitoye-716491207/,https://github.com/AKforCodes,Using AI to solve real-world problems.,None,No,, +mohammedali240403@gmail.com,+447983888958,Clifton Coders,https://www.linkedin.com/in/mohammed-ali-52ba71242/,https://github.com/T0mLam/clifton-coders,"ESG (Environmental, Social, and Governance) reporting for public companies. This is a critical challenge due to fragmented data, complex regulations, and high manual costs. + +My approach involves building an + +autonomous, multi-agent AI ""workforce"" using the CAMEL/OWL framework. This system will not only automate data gathering and analysis but also uniquely offer ""anti-greenwashing"" intelligence to ensure transparency. Ultimately, I aim to create more trust and efficiency in the market. + +More information on the GitHub repo :)",Other,No,, +tom.lam@odns.hk,+447508122399,Clifton Coders,www.linkedin.com/in/tom-kh-lam,https://github.com/T0mLam/clifton-coders,"We're tackling the messy, time-consuming process of ESG (Environmental, Social, and Governance) reporting, where scattered data makes it hard to track performance or spot risks. ESG covers a company’s sustainability and ethical impact—from emissions to labor practices and governance. Our project, ESG-Agent, uses the CAMEL multi-agent framework to automate this workflow: DataScoutAgent gathers ESG data from public APIs, ComplianceAnalystAgent looks at the raw info and scores it, ControversyMonitorAgent scans news and social media for red flags and fact-checks emerging claims, ReportGeneratorAgent builds clear reports, and CoordinatorAgent keeps everything in sync. The result: ESG reporting that's faster, smarter, and more accessible for startups and individuals.",None,No,, +samitahir018@gmail.com,+447867980616,Crackedev’s,https://linkedin.com/in/samitahir1,https://github.com/trenchsheikh,"Not sure yet, we will figure out on the day. The hackathon adrenaline brings the most creative ideas out",None,Yes,, +ananyabhalla101@gmail.com,+447818370784,Crackers,https://www.linkedin.com/in/ananyabhalla/,https://github.com/AnanyaBhalla,"•⁠ ⁠Improving interaction with AI for humankind - embodied AI / second brain +•⁠ ⁠Optimising on-device ML model inference +•⁠ ⁠Virtual cell foundation model",None,No,, +ishangodawatta@gmail.com,+447397235771,Crackers,https://www.linkedin.com/in/ishan-godawatta/,https://github.com/IshanG97,"- Improving interaction with AI for humankind - embodied AI / second brain +- Optimising on-device ML model inference +- Virtual cell foundation model",None,No,, +ttdanielik@gmail.com,+447736732678,Crackers,https://linkedin.com/in/daniel-ik-human,https://github.com/danny-1k,"- Democratising Quantitative techniques in Finance +- Improving interaction with AI for humankind +- Optimising On-device ML model inference +- Virtual cell foundation model",None,No,, +jpw993@gmail.com,+447824117513,CryptoScanner,https://linkedin.com/in/james-defi,https://github.com/jpw993,Using machine learning to find optimal exchange rates (via swap paths) on decentralised exchanges,None,Yes,, +abdeali@siya.tech,+447438222709,Ecoloop,https://linkedin.com/in/abdealisiyawala,https://github.com/abdealisiya,Material Recycling,Other,No,, +enaihouwaspaul@gmail.com,+447440520013,Gebo,https://linkedin.com/in/enaiho-uwas-paul,https://www.github.com/EnaihoVFX,Content creation with ai,None,No,, +hello@calebareeveso.com,+447930002899,Genz,https://linkedin.com/in/caleb-areeveso,https://github.com/calebareeveso,Using AI for safety ,None,No,, +agupta0595@gmail.com,+447404682594,I don’t have a team yet,https://linkedin.com/in/aditya-gupta-29b9b4241,https://github.com/Aditya-1301,Not completely sure yet,Vegetarian,Yes,, +mluqueanguita@gmail.com,+447599973293,I don't have one! ,https://linkedin.com/in/marialuqueanguita,https://github.com/marialuquea,Mainly trying to automate my daily manual tasks,None,Yes,, +mattusmarcus@gmail.com,+447871225477,InfinityID,https://linkedin.com/in/marcus-mattus-850b08154,https://github.com/marcusmattus,How people think as a collective ,Other,Yes,, +rod@aip.engineer,+447354006128,Jentic,https://linkedin.com/in/rodriveracom,http://github.com/ducktyper-ai/quackverse,,None,No,, +ashwin.sridhar-kamakshi24@imperial.ac.uk,+447411161282,Kalaxian,https://linkedin.com/in/theashwinsk,https://github.com/ashwinsk01,"Contextual Analysis for AI +Data Monetization +Local AI efficiency",None,No,, +henry01allen@gmail.com,+447484718110,Kallencho,https://linkedin.com/in/henry-allen-52868926b,xyz,,None,No,, +luketervit@gmail.com,+447757020305,Kallencho,https://www.linkedin.com/in/luke-tervit?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=android_app,http://github.com/luketervit,"Productivity, gamification, autotagging",None,No,, +leocamacho707@gmail.com,+447927612815,Kamallen,https://linkedin.com/in/leo-camacho,https://github.com/theCampel,AI B2B SAAS,None,No,, +bilalmustafasheikh@icloud.com,+447498835682,kiro ,https://linkedin.com/in/bilal-mustafa-sheikh,https://github.com/bilal-mustafa10,,Vegetarian,Yes,, +agvanuk.ai@gmail.com,+447881148600,Model Overfitters,London,https://github.com/AgvanTsydypov,"The most interesting tasks for me are those related to markets and quotes, since they suit me better and are also easier to imagine.",None,Yes,, +dcheng@solution4u.com,+447727821753,Mythic,https://linkedin.com/in/davecheng82,https://github.com/masterasnackin,"The problems of Bias and fairness which continue to plague AI systems, with over 60% of AI applications containing some degree of bias in testing. we are actively developing new approaches to detect, measure, and mitigate algorithmic bias, particularly as AI systems are deployed in high-stakes domains like healthcare, criminal justice, and hiring. + +Also explainability and transparency represent another major frontier, as the ""black box"" nature of many AI systems undermines trust and accountability. Researchers are pursuing methods to make AI decision-making processes more interpretable, especially for critical applications where understanding the reasoning behind decisions is essential.",None,No,, +foxreymann@gmail.com,+447930005222,Mythic,https://linkedin.com/in/foxreymann,https://github.com/repo-with-what??,the ones that you're going to provide for the hackathon,Other,No,, +lelonompumelelo@gmail.com,+447769729361,Mythic,https://linkedin.com/in/nompumelelo,https://github.com/elolelo,,None,No,, +desperado_shots@yahoo.co.uk,+447572516434,N/A,https://linkedin.com/in/andrei-baloiu-bab11943,http://github.com/andysign/#https://github.com/dc-andysign,"I am interested in solving multiple problems, specifically protect against hacks such as Direct Prompt Injection / Model Replacement OR Model Poisoning / Backdoor Model Attacks etc.",None,Yes,, +farhath.al@gmail.com,+447453179983,n/a,https://www.linkedin.com/in/farhath-razzaque/,https://github.com/codehath,,None,Yes,, +tallalmirza24@gmail.com,+447500444494,N/A,https://linkedin.com/in/null-pointer,https://github.com/tallalnparis4ev,,None,Yes,, +systerr@gmail.com,+447471957527,none,https://linkedin.com/in/andrey-logunov,https://github.com/Systerr,"web3 related, AI agents, big data",None,Yes,, +oliver@kingshott.com,+447835873851,oliland,https://www.linkedin.com/in/oliland/,https://github.com/oliland/,"AI, Computer Graphics, Computer Vision for the Built Environment",None,Yes,, +will.norris@hey.com,+447554065079,Pickle,https://linkedin.com/in/will-norris,https://github.com/wherethereisawill,Using Google Gemini to power my warehouse automation robot.,None,No,,Accepted +fahretdinov28@gmail.com,+447833160265,QMML,http://linkedin.com/in/emil-fakhretdinov,https://github.com/serhalahmad/unibots,robotics,None,No,, +karl.frjo@gmail.com,+4915788282502,QMML,https://www.linkedin.com/in/karljohannes,https://github.com/serhalahmad/unibots,Robotics,None,No,, +maxkho00@gmail.com,+447512387081,QMML,https://www.linkedin.com/in/maxim-khovansky?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=android_app,https://github.com/serhalahmad/unibots,Robotics,None,No,, +dan@sibylline.group,+447393215678,Sibylline Labs,https://linkedin.com/in/3266miles,https://github.com/3266miles,Hard ones,None,No,, +himanshuragarwal@gmail.com,+447424600690,Signal Stack,https://linkedin.com/in/himanshu-ragarwal,https://github.com/itsshimanshuagarwal,"I’m excited to solve problems at the intersection of venture capital, startup data driven signal discovery, and AI especially around making investor-founder matching more transparent and data-driven. I’m exploring how to surface hidden signals, map influence networks, and empower operators and VCs with real-time intelligence for smarter decisions.",Vegan,No,, +vidhi@vidhidesai.com,+447721067416,Still forming a team!,https://linkedin.com/in/vidhidesai6,vidhidesai1,,Vegetarian,Yes,, +abhishekmaran1997@gmail.com,+447356221666,Superfluid,https://linkedin.com/in/abhishekmaran,https://github.com/AbhishekMaran123,,Vegetarian,Yes,, +rajan.drw@gmail.com,+447855508775,TBD,https://linkedin.com/in/rajanpatel,https://github.com/djcoder100,Medical for elderly ,None,Yes,, +shahidanjumshaikhmain@gmail.com,+447305253481,Team Link,https://linkedin.com/in/shahid-anjum-shaikh-54161025b,https://github.com/daScuderiaSha?tab=repositories,Just solving life problems through tech.,Pescetarian,No,, +zachariah.zergoug@gmail.com,+447305796975,Team Link,https://linkedin.com/in/zachariah-zergoug,https://github.com/ZacZe,I'm interesting in making technology more accessible to all and making life more fun via the use of tech. I'm also interested about building things that help both people and communities grow. ,Pescetarian,No,, +yap.engkean@gmail.com,+60122432882,team Wesleyan,https://www.linkedin.com/in/marcus-yap-8b8401218/,https://github.com/rockgodthecoder,"Sales workflow, traditional business workflow. Also I lost my old git from uni email & now I am getting back into projects. ",None,No,, +dwitee@gmail.com,+447881969839,TeamA,https://www.linkedin.com/in/dwiteekrishnapanda,https://github.com/Dwitee,"text summarization, video summarization, automating game assets creation, image analysis, real time , text to audio, text to image text to video",Vegetarian,Yes,, +george.profenza@gmail.com,+447943507142,TeamA,https://linkedin.com/in/georgeprofenza,http://github.com/orgicus/,I'm interested in connecting Gemini to app installed on a computer so it can learn how to use them and automate time consuming tasks. (e.g. learning how to track 100 people across 12 camera feeds for a psychology experiment around space navigation tasks),None,Yes,, +j_lingi@outlook.com,+447742402501,temp1,https://linkedin.com/in/johnlingi,https://github.com/Johnxjp,,None,No,, +michael.james015@gmail.com,+447595329742,temp1,https://www.linkedin.com/in/michaeljames015/,https://github.com/MikeJay-ctrl,"payment systems +auditing +productivity +generative media",None,No,, +samuelola12@hotmail.com,+447964859525,Temp1,https://linkedin.com/in/samuel-ola,https://github.com/samuelolaa,"Productivity, health & fitness, education",None,No,, +ahussain2034@gmail.com,+447543840883,ThunderWheelStrike, www.linkedin.com/in/athar-hussain-1b333b244,https://github.com/Athar230,"Interested in combining coding, gaming and electronics ",Other,No,, +e.asadiku@gmail.com,+447549128867,ThunderWillStrike,https://www.linkedin.com/in/emmanuel-sadiku-220602334/,https://github.com/projects,"Connect software systems to external devices, APIs, and real-world systems so they can interact with environments, people, and services outside the computers",None,No,, +venkatesh.sahana1606@gmail.com,+447774884894,Troubleseekers,https://linkedin.com/in/sahana-venkatesh,https://github.com/Sahanave,Multimodal,None,No,, +alexchoidev@gmail.com,+447835588859,UM,https://linkedin.com/in/alexchoi1,https://github.com/alexechoi,,None,Yes,, +maganchin@gmail.com,+19145361292,UM FIG,https://www.linkedin.com/in/maganchin,http://www.github.com/maganchin,"I’m interested in GovTech, voice agents, and building things that will matter in an AI-driven future!",None,No,, +stanvanbaarsen@hotmail.com,+31640516654,UM FIG,https://linkedin.com/in/stan-van-baarsen,https://www.github.com/StanvBaarsen,"I’m interested in GovTech, voice agents, and I like thinking about what an AI-driven future society could look like and building for that",None,No,, +ehewes@outlook.com,+447480556916,UM GemiFish,https://linkedin.com/in/ellis-h-4b061024b,https://github.com/jalliet/UM-GemiFish,"Problem to solve: +Stripping down physical-digital interaction barriers during complex assembly tasks. Specifically: how can computer vision and contextual AI eliminate the cognitive load of translating 2D instructions into 3D spatial reasoning? + +Value proposition: +As a proof-of-concept, we use LEGO sets to get the point across. +When people build complex LEGO sets, they spend 60% of their time hunting for pieces and deciphering ambiguous diagrams rather than actually building. We think this is actually a fundamental UX problem where the paper/digital instructions fail to bridge into physical manipulation. + +We would like to create (near, for POC) real-time computer vision systems that can recognise physical objects and provide contextual, spatial guidance through VR overlays - essentially making AI a collaborative building partner rather than a passive instruction manual. + +The broader opportunity: this same approach is highly valuable in industrial assembly, medical procedures, educational construction tasks, and in robotics: essentially any domain where humans need to translate complex instructions into precise physical actions.",None,No,, +thej.i.alliet@gmail.com,+447355297995,UM GemiFish ,https://linkedin.com/in/jalliet,https://github.com/jalliet/UM-GemiFish,"The problem we aim to solve: +Stripping down physical-digital interaction barriers during complex assembly tasks. Specifically: How can computer vision and contextual AI eliminate the cognitive load of translating 2D instructions into 3D spatial reasoning? + +The value we see in our proposition: +As a proof-of-concept, we will use LEGO sets to get the point across. +When people build complex LEGO sets, they spend 60% of their time hunting for pieces and deciphering diagrams rather than actually building. We think this is actually a fundamental UX problem where the paper instructions fail to bridge into proper physical manipulation. +We would like to create (near, for POC) real-time computer vision systems that can recognise physical objects and provide contextual, spatial guidance through VR overlays. AI acts as your collaborative building partner rather than a passive instruction manual. + +The broader opportunity: +We believe the approach outlined above will be highly valuable in countless applications, including industrial assembly, medical procedures, educational use cases, construction tasks, robotics to name but a few. Essentially, any domain where humans need to translate complex instructions into precise physical actions.",None,No,, +aydayazdani@gmail.com,+447387588323,UM Git Ready Go,https://linkedin.com/in/ayda-yazdani-63a5301ba,https://github.com/ayda-yazdani,"I'm interested in solving workflow automation specifically in healthcare, particularly clinical documentation. Healthcare providers spend excessive time on administrative tasks instead of patient care, creating both inefficiency and burnout. + +The problem is both technically fascinating, requiring seamless integration with existing systems, and impactful for society. I would love to contribute to something with real promise in reducing provider burnout while improving care quality. + +This solution for workflow automation could alsoscale across sectors. The same AI techniques for processing unstructured information could transform legal case preparation, financial compliance reporting, or educational assessment documentation.",None,No,, +meganullahs@gmail.com,+447548475539,UM Git Ready Go,https://www.linkedin.com/in/megan-ullahs-0732202a0?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app,https://github.com/meganu06,,None,No,, +calugarucosmin20@yahoo.com,+447859767602,UM Hub Scholars,https://linkedin.com/in/cosmincal,https://github.com/Cozkou,Questioning workflow + LLM cross-referencing with consensus & arbitration,None,No,, +k@kennyo.tech,+447490676668,UM Hub Scholars,https://linkedin.com/in/kennysoiii,https://github.com/KennyOliver,Questioning workflow + LLM cross-referencing with consensus & arbitration,None,No,, +leyansterferns2005@gmail.com,+447882771339,UM Hub Scholars,https://linkedin.com/in/leyanster-fernandes5,https://github.com/LeyansterFernandes,Questioning workflow + LLM cross-referencing with consensus & arbitration,None,No,, +stoian.alberto11@gmail.com,+447542660289,UM Hub Scholars,https://www.linkedin.com/in/andrei-stoian-a29a12249/,https://github.com/Vulpin23?tab=repositories,Questioning workflow + LLM cross-referencing with consensus & arbitration ,None,No,, +r.wong21@imperial.ac.uk,+447545499522,UM Magnets,https://linkedin.com/in/raymond-wong-a226a8154,https://github.com/RaymondWKWong,"I'm interested in solving real world problems, this can vary from impactful issues to small fun problems people have day to day which accumulates time. I wish to utilise AI agents, semantic search retrieval to achieve these solutions as a means of imitating human workflows.",None,No,, +roykarlonuyda@gmail.com,+447508812103,UM Magnets,Linkedin.com/in/roykarlo,http://github.com/honeykeys,"I'm interested in agentic problems, not just the standard agents with tool calling and semantic search retrieval. Something different!",None,No,, +benclarke.website@gmail.com,+447342791480,UM Soham Parekh,https://www.linkedin.com/in/benclarkeedinburgh?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app,https://github.com/benclarkegithub,Something involving agents as the solution!,Other,No,,Accepted +faiz2001@gmail.com,+447386808228,UM Soham Parekh,https://www.linkedin.com/in/mohammed-faiz-47585017a/,https://github.com/Mohammed-Faizzzz/,,Other,Yes,,Accepted +sebastianozuddas1@gmail.com,+447598064088,UM Soham Parekh,https://linkedin.com/in/sebzuddas,https://github.com/sebzuddas,Using AI/ML approaches to enhance supply chain resilience via a distributed manufacturing network. ,None,Yes,,Accepted +davidkubanek98@gmail.com,+447818817784,UM_PORG,https://www.linkedin.com/in/david-kubanek,https://github.com/davidkubanek,"Learning new concepts using analogies, transferring knowledge from one domain to another using personalized logical bridges",None,No,,Accepted +hrubaanna1@gmail.com,+420725111808,UM_PORG,https://linkedin.com/in/anna-hruba,https://github.com/hrubaanna,"Learning new concepts using analogies, transferring knowledge from one domain to another using personalized logical bridges",None,No,,Accepted +tomhr@email.cz,+420601564101,UM_PORG,https://linkedin.com/in/tomashrdlicka2000,https://github.com/tomashrdlicka,"Learning new concepts using analogies, transferring knowledge from one domain to another using personalized logical bridges. + +Our team is focusing on this problem as we are a group of longtime friends and we noticed that we can skip a lot of intermediate steps when switching from topic to topic in debates. We are trying to emulate this with llms.",None,No,,Accepted +simonowisdom@gmail.com,+447909413457,UM-GemiFish,https://linkedin.com/in/iamsimonwisdom,https://github.com/jalliet/UM-GemiFish,,Vegan,No,, +rohan1198@gmail.com,+447594542187,UM-PI,https://linkedin.com/in/rohan-khandekar-b802b8147,https://github.com/rohan1198,"Physical Intelligence, intelligent robotics, etc.",None,No,, +davidgelberg@gmail.com,+447955814035,Unicorn Mafia (TBA),https://linkedin.com/in/davidgelberg,https://github.com/unicorn-mafia,,None,No,, +shounak.naskar24@imperial.ac.uk,+447823645316,vector,https://linkedin.com/in/shounaknaskar,https://github.com/s24imlon,I am interested in solving problems in healthcare and legaltech - automating or simulating verdicts in civil cases.,None,No,, +kenji.phang@outlook.com,+447766300461,Wesleyans,https://linkedin.com/in/kenjiphang,https://github.com/KenjiPcx,"AI for content creation, video generation +AI to make product launch videos",None,No,, +tom@expansify.ai,+447740464526,Xyrra AI,https://linkedin.com/in/tom-nash-984a1a49,https://github.com/markets-flow/marketsflow-saas,We are building the next DeFi with AI at the heart of it.,None,No,, \ No newline at end of file From 0c013318bbf07f7a5a88986037446774ea430eb2 Mon Sep 17 00:00:00 2001 From: eddydavies Date: Wed, 16 Jul 2025 00:17:49 +0100 Subject: [PATCH 009/211] feat: refactor LinkedIn API integration and enhance applicant processing - Introduce a new `linkedin-api.ts` module for LinkedIn data processing. - Update `ProcessingLoader` to display LinkedIn progress and status. - Modify applicant processing to include LinkedIn URL handling with progress tracking. - Refactor existing functions to improve clarity and maintainability. - Enhance error handling and user feedback for LinkedIn data retrieval. - Update UI components to reflect changes in LinkedIn data processing. --- frontend/src/app/api/applicants/route.ts | 3 +- .../app/board/components/ConsoleSidebar.tsx | 5 +- frontend/src/app/board/page.tsx | 18 +- frontend/src/components/BoardSidebar.tsx | 9 +- frontend/src/components/ProcessingLoader.tsx | 47 ++- frontend/src/lib/cv.ts | 260 +------------ frontend/src/lib/linkedin-api.ts | 344 ++++++++++++++++++ .../src/lib/simple_tests/batch-process.ts | 5 +- .../lib/simple_tests/hackathon-analysis.ts | 2 +- .../simple_tests/linkedin-api-quick-test.ts | 84 +++++ .../src/lib/simple_tests/linkedin-api-test.ts | 60 +++ .../simple_tests/linkedin-progress-demo.tsx | 99 +++++ 12 files changed, 660 insertions(+), 276 deletions(-) create mode 100644 frontend/src/lib/linkedin-api.ts create mode 100644 frontend/src/lib/simple_tests/linkedin-api-quick-test.ts create mode 100644 frontend/src/lib/simple_tests/linkedin-api-test.ts create mode 100644 frontend/src/lib/simple_tests/linkedin-progress-demo.tsx diff --git a/frontend/src/app/api/applicants/route.ts b/frontend/src/app/api/applicants/route.ts index 9c9a9b5..6bd8f5b 100644 --- a/frontend/src/app/api/applicants/route.ts +++ b/frontend/src/app/api/applicants/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Applicant } from '@/lib/interfaces/applicant'; import { CvData } from '@/lib/interfaces/cv'; import { GitHubData } from '@/lib/interfaces/github'; -import { processCvPdf, validateAndCleanCvData, processLinkedInPdf, processLinkedInUrl } from '@/lib/cv'; +import { processCvPdf, validateAndCleanCvData, processLinkedInUrl } from '@/lib/cv'; import { processGitHubAccount } from '@/lib/github'; import { analyzeApplicant } from '@/lib/analysis'; import * as fs from 'fs'; @@ -103,7 +103,6 @@ async function processApplicantAsync(applicantId: string, githubUrl?: string, li // Generate unique temp directory suffixes to prevent race conditions const cvTempSuffix = `cv_${applicantId}_${Date.now()}`; - const linkedinTempSuffix = `linkedin_${applicantId}_${Date.now()}`; // Process CV, LinkedIn, and GitHub all in parallel console.log(`Processing all data sources for applicant ${applicantId}`); diff --git a/frontend/src/app/board/components/ConsoleSidebar.tsx b/frontend/src/app/board/components/ConsoleSidebar.tsx index 5619f14..386ff82 100644 --- a/frontend/src/app/board/components/ConsoleSidebar.tsx +++ b/frontend/src/app/board/components/ConsoleSidebar.tsx @@ -4,6 +4,7 @@ import { Trash2, LayoutDashboard, Users, Settings, CreditCard, Cog, ChevronRight import { Button } from '../../../components/ui/button'; import { useApplicants } from '../../../lib/contexts/ApplicantContext'; import Link from 'next/link'; +import Image from 'next/image'; import { usePathname } from 'next/navigation'; interface ConsoleSidebarProps { @@ -65,9 +66,11 @@ export default function ConsoleSidebar({ {/* Logo Header */}
- Unmask diff --git a/frontend/src/app/board/page.tsx b/frontend/src/app/board/page.tsx index 75e842c..ca1bece 100644 --- a/frontend/src/app/board/page.tsx +++ b/frontend/src/app/board/page.tsx @@ -1062,14 +1062,16 @@ function BoardPageContent() {
{/* Start Interview Button */} -
- -
+ {selectedCandidate.status !== 'failed' && ( +
+ +
+ )}
) diff --git a/frontend/src/components/BoardSidebar.tsx b/frontend/src/components/BoardSidebar.tsx index 8013387..3e7ef81 100644 --- a/frontend/src/components/BoardSidebar.tsx +++ b/frontend/src/components/BoardSidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import Image from 'next/image'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState, useCallback, memo, useMemo, useEffect } from 'react'; import { LayoutDashboard, Users, Plus, ChevronDown, Settings, Check, Search } from 'lucide-react'; @@ -180,9 +181,11 @@ const BoardSidebarComponent = ({ isCollapsed, onToggle }: BoardSidebarProps) =>
{isCollapsed ? (
- Unmask
{/* LinkedIn Analysis */} - {(applicant.linkedinData || status === 'processing') && ( + {(applicant.linkedinData || status === 'processing' || linkedinProgress) && (
-
+
LinkedIn Analysis {applicant.linkedinData && ( )} + {linkedinProgress?.status === 'error' && ( + + + + )}
+ + {/* LinkedIn Progress Details */} + {linkedinProgress && !applicant.linkedinData && ( +
+

+ {linkedinProgress.message} +

+ {linkedinProgress.percentage && linkedinProgress.status !== 'error' && ( +
+
+
+ )} + {linkedinProgress.status !== 'error' && ( +

+ {linkedinProgress.attempt}/{linkedinProgress.maxAttempts} attempts +

+ )} +
+ )} + {applicant.linkedinData && (
{applicant.linkedinData.jobTitle && ( diff --git a/frontend/src/lib/cv.ts b/frontend/src/lib/cv.ts index 4cb6cf0..5e24a7d 100644 --- a/frontend/src/lib/cv.ts +++ b/frontend/src/lib/cv.ts @@ -1,4 +1,4 @@ -import { CvData, ContractType, LanguageLevel } from './interfaces/cv' +import { CvData, ContractType, LanguageLevel, Experience, Language } from './interfaces/cv' import { Groq } from 'groq-sdk' import * as fs from 'fs' import * as path from 'path' @@ -826,259 +826,5 @@ export function saveCvDataToJson(cvData: CvData, outputPath: string): void { } } -// LinkedIn API Response interfaces -interface LinkedInApiExperience { - company: string; - title: string; - location?: string; - start_date: string; - end_date: string; - description_html?: string; -} - -interface LinkedInApiLanguage { - title: string; - subtitle?: string; -} - -interface LinkedInApiResponse { - first_name?: string; - last_name?: string; - name?: string; - city?: string; - about?: string; - position?: string; - url?: string; - input_url?: string; - connections?: number; - followers?: number; - linkedin_id?: string; - country_code?: string; - current_company?: { - title?: string; - name?: string; - }; - avatar?: string; - banner_image?: string; - experience?: LinkedInApiExperience[]; - languages?: LinkedInApiLanguage[]; -} - -/** - * Convert LinkedIn API response to ProfileData format - * @param linkedinApiData - LinkedIn API response data - * @returns ProfileData compatible with our system - */ -export function convertLinkedInApiToProfileData(linkedinApiData: LinkedInApiResponse | LinkedInApiResponse[]): CvData { - const data = Array.isArray(linkedinApiData) ? linkedinApiData[0] : linkedinApiData; - - // Extract name parts - const firstName = data.first_name || ''; - const lastName = data.last_name || ''; - const fullName = data.name || ''; - - // If we don't have first/last name but have full name, try to split - let finalFirstName = firstName; - let finalLastName = lastName; - - if (!firstName && !lastName && fullName) { - const nameParts = fullName.split(' '); - finalFirstName = nameParts[0] || ''; - finalLastName = nameParts.slice(1).join(' ') || ''; - } - - // Convert experience - simplified for now - const professionalExperiences: any[] = (data.experience || []).map((exp: LinkedInApiExperience) => ({ - companyName: exp.company || '', - title: exp.title || '', - location: exp.location || '', - type: 'PERMANENT_CONTRACT', // Default, could be enhanced - startYear: parseLinkedInYear(exp.start_date) || 0, - startMonth: parseLinkedInMonth(exp.start_date), - endYear: exp.end_date === 'Present' ? undefined : parseLinkedInYear(exp.end_date), - endMonth: exp.end_date === 'Present' ? undefined : parseLinkedInMonth(exp.end_date), - ongoing: exp.end_date === 'Present', - description: exp.description_html || '', - associatedSkills: [] - })); - - // Convert languages - simplified for now - const languages: any[] = (data.languages || []).map((lang: LinkedInApiLanguage) => ({ - language: lang.title || '', - level: 'PROFESSIONAL' // Default level - })); - - // Extract skills from various sources - const skills: string[] = []; - if (data.position) skills.push(data.position); - - const profileData: CvData = { - firstName: finalFirstName, - lastName: finalLastName, - address: data.city || '', - email: '', // LinkedIn API doesn't typically provide email - phone: '', - linkedin: data.url || data.input_url || '', - github: '', - personalWebsite: '', - professionalSummary: data.about || '', - jobTitle: data.position || data.current_company?.title || '', - professionalExperiences, - otherExperiences: [], - educations: [], // Could be enhanced if education data is available - skills, - languages, - publications: [], - distinctions: [], - hobbies: [], - references: [], - certifications: [], - other: { - connections: data.connections, - followers: data.followers, - linkedinId: data.linkedin_id, - countryCode: data.country_code, - currentCompany: data.current_company, - avatar: data.avatar, - bannerImage: data.banner_image - }, - source: 'linkedin' - }; - - return profileData; -} - -/** - * Helper function to parse year from LinkedIn date string - */ -function parseLinkedInYear(dateStr: string): number | undefined { - if (!dateStr || dateStr === 'Present') return undefined; - - // Try to extract year from various formats - const yearMatch = dateStr.match(/(\d{4})/); - return yearMatch ? parseInt(yearMatch[1]) : undefined; -} - -/** - * Helper function to parse month from LinkedIn date string - */ -function parseLinkedInMonth(dateStr: string): number | undefined { - if (!dateStr || dateStr === 'Present') return undefined; - - const monthNames = [ - 'jan', 'feb', 'mar', 'apr', 'may', 'jun', - 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' - ]; - - const monthMatch = dateStr.toLowerCase().match(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/); - if (monthMatch) { - return monthNames.indexOf(monthMatch[1]) + 1; - } - - return undefined; -} - -/** - * Process LinkedIn URL using BrightData API - * @param linkedinUrl - LinkedIn profile URL - * @returns ProfileData from LinkedIn API - */ -export async function processLinkedInUrl(linkedinUrl: string): Promise { - const apiKey = process.env.BRIGHTDATA_API_KEY; - const datasetId = process.env.BRIGHTDATA_DATASET_ID || 'gd_l1viktl72bvl7bjuj0'; - - if (!apiKey) { - throw new Error('BRIGHTDATA_API_KEY environment variable is not set'); - } - - try { - const response = await fetch('https://api.brightdata.com/datasets/v3/trigger', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify([{ url: linkedinUrl }]), - // Add query parameters - }); - - if (!response.ok) { - throw new Error(`LinkedIn API request failed: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - // The API returns a snapshot_id, we need to poll for results - const snapshotId = data.snapshot_id; - - if (!snapshotId) { - throw new Error('No snapshot_id returned from LinkedIn API'); - } - - // Poll for results - const results = await pollForLinkedInResults(snapshotId, apiKey); - - if (!results || results.length === 0) { - throw new Error('No LinkedIn data returned from API'); - } - - // Convert the first result to our ProfileData format - return convertLinkedInApiToProfileData(results[0]); - - } catch (error) { - console.error('Error processing LinkedIn URL:', error); - throw new Error(`Failed to process LinkedIn URL: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -} - -/** - * Poll for LinkedIn API results - * @param snapshotId - Snapshot ID from initial API call - * @param apiKey - API key for authentication - * @returns LinkedIn API results - */ -async function pollForLinkedInResults(snapshotId: string, apiKey: string): Promise { - const maxAttempts = 30; // 5 minutes with 10-second intervals - const pollInterval = 10000; // 10 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const response = await fetch(`https://api.brightdata.com/datasets/v3/snapshot/${snapshotId}`, { - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - throw new Error(`Polling failed: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - // Check if the snapshot is ready - if (data.status === 'ready' && data.data && data.data.length > 0) { - return data.data; - } - - // If not ready, wait and try again - if (data.status === 'running') { - await new Promise(resolve => setTimeout(resolve, pollInterval)); - continue; - } - - // If failed or other status - if (data.status === 'failed') { - throw new Error(`LinkedIn API processing failed: ${data.error || 'Unknown error'}`); - } - - } catch (error) { - if (attempt === maxAttempts - 1) { - throw error; - } - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - } - - throw new Error('LinkedIn API polling timed out'); -} +// Import LinkedIn API functions +export { processLinkedInUrl, convertLinkedInApiToProfileData } from './linkedin-api'; diff --git a/frontend/src/lib/linkedin-api.ts b/frontend/src/lib/linkedin-api.ts new file mode 100644 index 0000000..136ec82 --- /dev/null +++ b/frontend/src/lib/linkedin-api.ts @@ -0,0 +1,344 @@ +import { CvData, Experience, Language, ContractType } from './interfaces/cv'; + +// LinkedIn API Response interfaces +interface LinkedInApiExperience { + company: string; + title: string; + location?: string; + start_date: string; + end_date: string; + description_html?: string; +} + +interface LinkedInApiLanguage { + title: string; + subtitle?: string; +} + +interface LinkedInApiResponse { + first_name?: string; + last_name?: string; + name?: string; + city?: string; + about?: string; + position?: string; + url?: string; + input_url?: string; + connections?: number; + followers?: number; + linkedin_id?: string; + country_code?: string; + current_company?: { + title?: string; + name?: string; + }; + avatar?: string; + banner_image?: string; + experience?: LinkedInApiExperience[]; + languages?: LinkedInApiLanguage[]; +} + +/** + * Helper function to parse year from LinkedIn date string + */ +function parseLinkedInYear(dateStr: string): number | undefined { + if (!dateStr || dateStr === 'Present') return undefined; + + // Try to extract year from various formats + const yearMatch = dateStr.match(/(\d{4})/); + return yearMatch ? parseInt(yearMatch[1]) : undefined; +} + +/** + * Helper function to parse month from LinkedIn date string + */ +function parseLinkedInMonth(dateStr: string): number | undefined { + if (!dateStr || dateStr === 'Present') return undefined; + + const monthNames = [ + 'jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' + ]; + + const monthMatch = dateStr.toLowerCase().match(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/); + if (monthMatch) { + return monthNames.indexOf(monthMatch[1]) + 1; + } + + return undefined; +} + +/** + * Convert LinkedIn API response to ProfileData format + * @param linkedinApiData - LinkedIn API response data + * @returns ProfileData compatible with our system + */ +export function convertLinkedInApiToProfileData(linkedinApiData: LinkedInApiResponse | LinkedInApiResponse[]): CvData { + const data = Array.isArray(linkedinApiData) ? linkedinApiData[0] : linkedinApiData; + + // Extract name parts + const firstName = data.first_name || ''; + const lastName = data.last_name || ''; + const fullName = data.name || ''; + + // If we don't have first/last name but have full name, try to split + let finalFirstName = firstName; + let finalLastName = lastName; + + if (!firstName && !lastName && fullName) { + const nameParts = fullName.split(' '); + finalFirstName = nameParts[0] || ''; + finalLastName = nameParts.slice(1).join(' ') || ''; + } + + // Convert experience - simplified for now + const professionalExperiences: Experience[] = (data.experience || []).map((exp: LinkedInApiExperience) => ({ + companyName: exp.company || '', + title: exp.title || '', + location: exp.location || '', + type: ContractType.PERMANENT_CONTRACT, // Default, could be enhanced + startYear: parseLinkedInYear(exp.start_date) || 0, + startMonth: parseLinkedInMonth(exp.start_date), + endYear: exp.end_date === 'Present' ? undefined : parseLinkedInYear(exp.end_date), + endMonth: exp.end_date === 'Present' ? undefined : parseLinkedInMonth(exp.end_date), + ongoing: exp.end_date === 'Present', + description: exp.description_html || '', + associatedSkills: [] + })); + + // Convert languages - simplified for now + const languages: Language[] = (data.languages || []).map((lang: LinkedInApiLanguage) => ({ + language: lang.title || '', + level: 'PROFESSIONAL' // Default level + })); + + // Extract skills from various sources + const skills: string[] = []; + if (data.position) skills.push(data.position); + + const profileData: CvData = { + firstName: finalFirstName, + lastName: finalLastName, + address: data.city || '', + email: '', // LinkedIn API doesn't typically provide email + phone: '', + linkedin: data.url || data.input_url || '', + github: '', + personalWebsite: '', + professionalSummary: data.about || '', + jobTitle: data.position || data.current_company?.title || '', + professionalExperiences, + otherExperiences: [], + educations: [], // Could be enhanced if education data is available + skills, + languages, + publications: [], + distinctions: [], + hobbies: [], + references: [], + certifications: [], + other: { + connections: data.connections, + followers: data.followers, + linkedinId: data.linkedin_id, + countryCode: data.country_code, + currentCompany: data.current_company, + avatar: data.avatar, + bannerImage: data.banner_image + }, + source: 'linkedin' + }; + + return profileData; +} + +/** + * Poll for LinkedIn API results with progress callbacks + * @param snapshotId - Snapshot ID from initial API call + * @param apiKey - API key for authentication + * @param onProgress - Optional progress callback + * @returns LinkedIn API results + */ +async function pollForLinkedInResults( + snapshotId: string, + apiKey: string, + onProgress?: (progress: { attempt: number; maxAttempts: number; status: string; message: string }) => void +): Promise { + const maxAttempts = 30; // 5 minutes with 10-second intervals + const pollInterval = 10000; // 10 seconds + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + onProgress?.({ + attempt: attempt + 1, + maxAttempts, + status: 'polling', + message: `Checking LinkedIn data... (${attempt + 1}/${maxAttempts})` + }); + + const response = await fetch(`https://api.brightdata.com/datasets/v3/snapshot/${snapshotId}`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Polling failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Check if the snapshot is ready + if (data.status === 'ready' && data.data && data.data.length > 0) { + onProgress?.({ + attempt: attempt + 1, + maxAttempts, + status: 'ready', + message: 'LinkedIn data retrieved successfully!' + }); + return data.data; + } + + // If not ready, wait and try again + if (data.status === 'running') { + onProgress?.({ + attempt: attempt + 1, + maxAttempts, + status: 'running', + message: `LinkedIn processing in progress... (${Math.round(((attempt + 1) / maxAttempts) * 100)}%)` + }); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } + + // If failed or other status + if (data.status === 'failed') { + throw new Error(`LinkedIn API processing failed: ${data.error || 'Unknown error'}`); + } + + } catch (error) { + if (attempt === maxAttempts - 1) { + throw error; + } + onProgress?.({ + attempt: attempt + 1, + maxAttempts, + status: 'retrying', + message: `Retrying LinkedIn request... (${attempt + 1}/${maxAttempts})` + }); + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + throw new Error('LinkedIn API polling timed out'); +} + +export type LinkedInProgress = { + attempt: number; + maxAttempts: number; + status: 'starting' | 'polling' | 'running' | 'ready' | 'retrying' | 'error'; + message: string; + percentage?: number; +}; + +/** + * Process LinkedIn URL using BrightData API with progress tracking + * @param linkedinUrl - LinkedIn profile URL + * @param onProgress - Optional progress callback + * @returns ProfileData from LinkedIn API + */ +export async function processLinkedInUrl( + linkedinUrl: string, + onProgress?: (progress: LinkedInProgress) => void +): Promise { + const apiKey = process.env.BRIGHTDATA_API_KEY; + + if (!apiKey) { + throw new Error('BRIGHTDATA_API_KEY environment variable is not set'); + } + + try { + onProgress?.({ + attempt: 1, + maxAttempts: 30, + status: 'starting', + message: 'Initiating LinkedIn data extraction...', + percentage: 5 + }); + + const response = await fetch('https://api.brightdata.com/datasets/v3/trigger?dataset_id=gd_l1viktl72bvl7bjuj0&format=json&uncompressed_webhook=true', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([{ url: linkedinUrl }]), + }); + + if (!response.ok) { + throw new Error(`LinkedIn API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // The API returns a snapshot_id, we need to poll for results + const snapshotId = data.snapshot_id; + + if (!snapshotId) { + throw new Error('No snapshot_id returned from LinkedIn API'); + } + + onProgress?.({ + attempt: 1, + maxAttempts: 30, + status: 'polling', + message: 'LinkedIn processing started, waiting for results...', + percentage: 10 + }); + + // Poll for results with progress updates + const results = await pollForLinkedInResults(snapshotId, apiKey, (pollProgress) => { + onProgress?.({ + ...pollProgress, + percentage: 10 + (pollProgress.attempt / pollProgress.maxAttempts) * 80 // 10% to 90% + }); + }); + + if (!results || results.length === 0) { + throw new Error('No LinkedIn data returned from API'); + } + + onProgress?.({ + attempt: 30, + maxAttempts: 30, + status: 'ready', + message: 'Converting LinkedIn data...', + percentage: 95 + }); + + // Convert the first result to our ProfileData format + const profileData = convertLinkedInApiToProfileData(results[0]); + + onProgress?.({ + attempt: 30, + maxAttempts: 30, + status: 'ready', + message: 'LinkedIn analysis complete!', + percentage: 100 + }); + + return profileData; + + } catch (error) { + onProgress?.({ + attempt: 0, + maxAttempts: 30, + status: 'error', + message: `LinkedIn processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + percentage: 0 + }); + console.error('Error processing LinkedIn URL:', error); + throw new Error(`Failed to process LinkedIn URL: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} \ No newline at end of file diff --git a/frontend/src/lib/simple_tests/batch-process.ts b/frontend/src/lib/simple_tests/batch-process.ts index 83033da..85fedf5 100644 --- a/frontend/src/lib/simple_tests/batch-process.ts +++ b/frontend/src/lib/simple_tests/batch-process.ts @@ -352,8 +352,9 @@ async function main() { results.push({ success: true, applicantId, row }); console.log(`✅ Successfully processed applicant ${applicantId}`); } catch (error) { - results.push({ success: false, error: error.message, row }); - console.error(`❌ Failed to process row:`, error.message); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + results.push({ success: false, error: errorMessage, row }); + console.error(`❌ Failed to process row:`, errorMessage); } } diff --git a/frontend/src/lib/simple_tests/hackathon-analysis.ts b/frontend/src/lib/simple_tests/hackathon-analysis.ts index 71662de..2ac97e7 100644 --- a/frontend/src/lib/simple_tests/hackathon-analysis.ts +++ b/frontend/src/lib/simple_tests/hackathon-analysis.ts @@ -76,7 +76,7 @@ function calculateCandidateScore(applicant: Applicant): CandidateScore { if (profileData?.skills && profileData.skills.length > 5) { techScore += 20; // Good skill set } - if (profileData?.experience && profileData.experience.length > 0) { + if (profileData?.professionalExperiences && profileData.professionalExperiences.length > 0) { techScore += 20; // Has experience } } diff --git a/frontend/src/lib/simple_tests/linkedin-api-quick-test.ts b/frontend/src/lib/simple_tests/linkedin-api-quick-test.ts new file mode 100644 index 0000000..383dd94 --- /dev/null +++ b/frontend/src/lib/simple_tests/linkedin-api-quick-test.ts @@ -0,0 +1,84 @@ +/** + * Quick LinkedIn API connection test + * Tests just the initial API call without waiting for results + */ + +async function quickLinkedInApiTest() { + const testUrl = 'https://www.linkedin.com/in/satyanadella'; + + console.log('🔍 Quick LinkedIn API connection test...'); + console.log(`Testing with URL: ${testUrl}`); + + try { + // Check if API key is set + if (!process.env.BRIGHTDATA_API_KEY) { + console.error('❌ BRIGHTDATA_API_KEY environment variable is not set'); + console.log('💡 Set it with: export BRIGHTDATA_API_KEY=your_key_here'); + process.exit(1); + } + + console.log('✅ API key found'); + console.log('⏳ Testing LinkedIn API connection...'); + + const startTime = Date.now(); + + // Test the BrightData API trigger + const response = await fetch('https://api.brightdata.com/datasets/v3/trigger?dataset_id=gd_l1viktl72bvl7bjuj0&format=json&uncompressed_webhook=true', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.BRIGHTDATA_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([{ url: testUrl }]), + }); + + const endTime = Date.now(); + + console.log(`📊 API Response (${endTime - startTime}ms):`); + console.log(`- Status: ${response.status} ${response.statusText}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`❌ API Error: ${errorText}`); + process.exit(1); + } + + const data = await response.json(); + console.log(`- Snapshot ID: ${data.snapshot_id || 'N/A'}`); + console.log(`- Response: ${JSON.stringify(data, null, 2)}`); + + if (data.snapshot_id) { + console.log('✅ LinkedIn API connection successful!'); + console.log('📝 Snapshot created. Full processing would require polling.'); + } else { + console.log('⚠️ API responded but no snapshot_id returned'); + } + + } catch (error) { + console.error('❌ LinkedIn API test failed:'); + console.error(error instanceof Error ? error.message : 'Unknown error'); + + if (error instanceof Error) { + if (error.message.includes('401')) { + console.log('💡 Check your BRIGHTDATA_API_KEY is correct'); + } else if (error.message.includes('fetch')) { + console.log('💡 Check your internet connection'); + } + } + + process.exit(1); + } +} + +// Run the test +quickLinkedInApiTest() + .then(() => { + console.log('✅ Quick test completed'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ Test failed:', error); + process.exit(1); + }); + +export { quickLinkedInApiTest }; \ No newline at end of file diff --git a/frontend/src/lib/simple_tests/linkedin-api-test.ts b/frontend/src/lib/simple_tests/linkedin-api-test.ts new file mode 100644 index 0000000..ecad455 --- /dev/null +++ b/frontend/src/lib/simple_tests/linkedin-api-test.ts @@ -0,0 +1,60 @@ +import { processLinkedInUrl } from '../linkedin-api'; + +/** + * Simple test to check if LinkedIn API is working + * Run this with: npx tsx src/lib/simple_tests/linkedin-api-test.ts + */ +async function testLinkedInApi() { + const testUrl = 'https://www.linkedin.com/in/test-profile'; + + console.log('🔍 Testing LinkedIn API...'); + console.log(`Testing with URL: ${testUrl}`); + + try { + // Check if API key is set + if (!process.env.BRIGHTDATA_API_KEY) { + console.error('❌ BRIGHTDATA_API_KEY environment variable is not set'); + process.exit(1); + } + + console.log('✅ API key found'); + console.log('⏳ Processing LinkedIn URL...'); + + const startTime = Date.now(); + const result = await processLinkedInUrl(testUrl); + const endTime = Date.now(); + + console.log(`✅ LinkedIn API test completed in ${(endTime - startTime) / 1000}s`); + console.log('📊 Extracted data:'); + console.log(`- Name: ${result.firstName} ${result.lastName}`); + console.log(`- Job Title: ${result.jobTitle}`); + console.log(`- Location: ${result.address}`); + console.log(`- LinkedIn: ${result.linkedin}`); + console.log(`- Professional Summary: ${result.professionalSummary?.substring(0, 100)}...`); + console.log(`- Experience entries: ${result.professionalExperiences.length}`); + console.log(`- Skills: ${result.skills.length}`); + console.log(`- Languages: ${result.languages.length}`); + + return result; + + } catch (error) { + console.error('❌ LinkedIn API test failed:'); + console.error(error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} + +// Run the test if this file is executed directly +if (require.main === module) { + testLinkedInApi() + .then(() => { + console.log('✅ Test completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ Test failed:', error); + process.exit(1); + }); +} + +export { testLinkedInApi }; \ No newline at end of file diff --git a/frontend/src/lib/simple_tests/linkedin-progress-demo.tsx b/frontend/src/lib/simple_tests/linkedin-progress-demo.tsx new file mode 100644 index 0000000..5f261b2 --- /dev/null +++ b/frontend/src/lib/simple_tests/linkedin-progress-demo.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { processLinkedInUrl, type LinkedInProgress } from '../linkedin-api'; +import ProcessingLoader from '../../components/ProcessingLoader'; + +/** + * Demo component to test LinkedIn API with progress tracking + * Shows how to integrate LinkedIn progress into the UI + */ +export default function LinkedInProgressDemo() { + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const testLinkedInUrl = 'https://www.linkedin.com/in/satyanadella'; + + const startLinkedInTest = async () => { + setIsProcessing(true); + setProgress(undefined); + setResult(null); + setError(null); + + try { + const data = await processLinkedInUrl(testLinkedInUrl, (progressUpdate) => { + console.log('LinkedIn Progress:', progressUpdate); + setProgress(progressUpdate); + }); + + setResult(data); + console.log('LinkedIn Data:', data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + console.error('LinkedIn Error:', err); + } finally { + setIsProcessing(false); + } + }; + + if (isProcessing || progress) { + return ( + + ); + } + + return ( +
+
+

LinkedIn API Progress Demo

+ + + + {error && ( +
+

Error: {error}

+
+ )} + + {result && ( +
+

LinkedIn Data Retrieved!

+
+

Name: {result.firstName} {result.lastName}

+

Job Title: {result.jobTitle}

+

Location: {result.address}

+

Skills: {result.skills?.slice(0, 5).join(', ')}

+
+
+ )} + +
+

Test URL: {testLinkedInUrl}

+

This demo shows how LinkedIn API progress is tracked in real-time during processing.

+
+
+
+ ); +} \ No newline at end of file From 76fec2fd9130a2d351128eff4f02a0889609f0d1 Mon Sep 17 00:00:00 2001 From: eddydavies Date: Wed, 16 Jul 2025 08:44:05 +0100 Subject: [PATCH 010/211] feat: add debugging documentation and enhance applicant form logging - Introduce a new markdown file for debugging the unmask button issue on the /board page. - Enhance logging in the `handleCreateCandidate` function to trace form state and validation. - Update imports to reflect changes in the applicant interface structure. - Refactor error handling and button state management for improved user feedback. - Remove deprecated CV interface and consolidate applicant-related types for consistency. --- frontend/debug-unmask-button.md | 38 ++ .../app/board/components/NewApplicantForm.tsx | 23 +- frontend/src/app/board/page.tsx | 2 +- frontend/src/lib/analysis.ts | 2 +- frontend/src/lib/interfaces/applicant.ts | 154 +++++++- frontend/src/lib/interfaces/cv.ts | 367 ------------------ frontend/src/lib/interfaces/github.ts | 216 ----------- frontend/src/lib/interfaces/index.ts | 11 +- frontend/src/lib/linkedin-api.ts | 11 +- frontend/src/lib/{cv.ts => profile-pdf.ts} | 2 +- .../src/lib/simple_tests/batch-process.ts | 4 +- .../simple_tests/linkedin-progress-demo.tsx | 11 +- frontend/src/lib/simple_tests/test-cv.ts | 4 +- 13 files changed, 237 insertions(+), 608 deletions(-) create mode 100644 frontend/debug-unmask-button.md delete mode 100644 frontend/src/lib/interfaces/cv.ts delete mode 100644 frontend/src/lib/interfaces/github.ts rename frontend/src/lib/{cv.ts => profile-pdf.ts} (99%) diff --git a/frontend/debug-unmask-button.md b/frontend/debug-unmask-button.md new file mode 100644 index 0000000..dc1669e --- /dev/null +++ b/frontend/debug-unmask-button.md @@ -0,0 +1,38 @@ +# Debugging the Unmask Button Issue + +## Current Investigation + +The unmask button is not triggering the workflow when pressed on the /board page after filling: +- LinkedIn link +- GitHub link +- CV profile file upload + +## Findings So Far + +1. ✅ **API Endpoint Works**: Tested `/api/applicants` directly with curl - it responds correctly +2. ✅ **Form Validation Logic**: `isFormValid` checks `(linkedinUrl.trim() || cvFile) && !isCreating && !isLoading` +3. ✅ **Button Handler**: `handleCreateCandidate` function exists and calls `createApplicant` +4. ✅ **Context Method**: `createApplicant` in ApplicantContext posts to `/api/applicants` + +## Potential Issues to Check + +1. **Form Validation**: Is `isFormValid` returning `false`? +2. **Button State**: Is the button disabled due to validation? +3. **JavaScript Errors**: Are there console errors preventing execution? +4. **Network Issues**: Is the fetch request failing silently? +5. **Environment Variables**: Are required env vars (GROQ_API_KEY, BRIGHTDATA_API_KEY) set? + +## Debug Steps Needed + +1. Add console.log to `handleCreateCandidate` to see if it's called +2. Check browser console for JavaScript errors +3. Add logging to `createApplicant` to see where it fails +4. Verify form validation state +5. Check network tab for failed requests + +## Quick Fix Suggestions + +1. Add error boundary around the form +2. Add better error logging +3. Add visual feedback for button states +4. Check if ApplicantProvider is wrapping the component correctly \ No newline at end of file diff --git a/frontend/src/app/board/components/NewApplicantForm.tsx b/frontend/src/app/board/components/NewApplicantForm.tsx index ab1658b..8b29330 100644 --- a/frontend/src/app/board/components/NewApplicantForm.tsx +++ b/frontend/src/app/board/components/NewApplicantForm.tsx @@ -142,31 +142,48 @@ export default function NewApplicantForm({ onSuccess }: NewApplicantFormProps) { }; const handleCreateCandidate = async () => { + console.log('🔥 Unmask button clicked!'); + console.log('Form state:', { + linkedinUrl: linkedinUrl.trim(), + cvFile: cvFile?.name, + githubUrl: githubUrl.trim(), + isFormValid + }); + if (!linkedinUrl.trim() && !cvFile) { + console.log('❌ Form validation failed'); alert('Please provide either a LinkedIn profile URL or CV file'); return; } + console.log('✅ Form validation passed, starting creation...'); setIsCreating(true); try { + console.log('📤 Calling createApplicant...'); const applicantId = await createApplicant({ cvFile: cvFile || undefined, linkedinUrl: linkedinUrl.trim() || undefined, githubUrl: githubUrl.trim() || undefined }); + console.log('📥 createApplicant returned:', applicantId); + if (applicantId) { + console.log('✅ Success! Navigating to processing screen...'); resetForm(); - // Navigate immediately with replace to avoid back button issues - router.replace(`/board?id=${applicantId}`); + // Navigate immediately to the processing screen to show progress + router.replace(`/board?id=${applicantId}&processing=true`); // Call success callback if provided onSuccess?.(applicantId); + } else { + console.log('❌ createApplicant returned null'); + alert('Failed to create applicant. Please try again.'); } } catch (error) { - console.error('Failed to create applicant:', error); + console.error('❌ Failed to create applicant:', error); alert('Failed to create applicant. Please try again.'); } finally { setIsCreating(false); diff --git a/frontend/src/app/board/page.tsx b/frontend/src/app/board/page.tsx index ca1bece..2076b7a 100644 --- a/frontend/src/app/board/page.tsx +++ b/frontend/src/app/board/page.tsx @@ -9,7 +9,7 @@ import ProcessingLoader from '../../components/ProcessingLoader'; import CredibilityScore from '../../components/credibility-score'; import { useApplicants } from '../../lib/contexts/ApplicantContext'; import NewApplicantForm from './components/NewApplicantForm'; -import { CvData, Experience, Education } from '../../lib/interfaces/cv'; +import { CvData, Experience, Education } from '../../lib/interfaces/applicant'; import { GitHubData } from '../../lib/interfaces/github'; // Data Comparison Component diff --git a/frontend/src/lib/analysis.ts b/frontend/src/lib/analysis.ts index 8315e58..5e81a3f 100644 --- a/frontend/src/lib/analysis.ts +++ b/frontend/src/lib/analysis.ts @@ -1,6 +1,6 @@ import { Groq } from 'groq-sdk'; import { Applicant } from './interfaces/applicant'; -import { CvData } from './interfaces/cv'; +import { CvData } from './interfaces/applicant'; import { GitHubData } from './interfaces/github'; import { AnalysisResult } from './interfaces/analysis'; diff --git a/frontend/src/lib/interfaces/applicant.ts b/frontend/src/lib/interfaces/applicant.ts index 3593d04..0bde248 100644 --- a/frontend/src/lib/interfaces/applicant.ts +++ b/frontend/src/lib/interfaces/applicant.ts @@ -1,7 +1,159 @@ -import { ProfileData } from './cv'; import { GitHubData } from './github'; import { AnalysisResult, CvAnalysis, LinkedInAnalysis, GitHubAnalysis, CrossReferenceAnalysis } from './analysis'; +// Profile and CV data types (moved from cv.ts) +export interface ProfileData { + lastName: string + firstName: string + address: string + email: string + phone: string + linkedin: string + github: string + personalWebsite: string + professionalSummary: string + jobTitle: string + professionalExperiences: Experience[] + otherExperiences: Experience[] + educations: Education[] + skills: string[] + languages: Language[] + publications: string[] + distinctions: string[] + hobbies: string[] + references: string[] + certifications: Certification[] + other: Record // Flexible field for any additional data + source: 'cv' | 'linkedin' // Track the source of this profile data +} + +// Backward compatibility alias +export type CvData = ProfileData; + +export interface Certification { + title: string + issuer: string + issuedYear: number + issuedMonth?: number // Optional month (1-12) +} + +export interface Experience { + companyName?: string + title?: string + location: string + type: ContractType + startYear: number + startMonth?: number // Optional month (1-12) + endYear?: number // Optional if ongoing + endMonth?: number // Optional month (1-12) + ongoing: boolean + description: string + associatedSkills: string[] +} + +export interface Education { + degree: string + institution: string + location: string + startYear: number + startMonth?: number // Optional month (1-12) + endYear?: number // Optional if ongoing + endMonth?: number // Optional month (1-12) + ongoing: boolean + description: string + associatedSkills: string[] +} + +export interface Language { + language: string + level: LanguageLevel +} + +export enum LanguageLevel { + BASIC_KNOWLEDGE = 'BASIC_KNOWLEDGE', + LIMITED_PROFESSIONAL = 'LIMITED_PROFESSIONAL', + PROFESSIONAL = 'PROFESSIONAL', + FULL_PROFESSIONAL = 'FULL_PROFESSIONAL', + NATIVE_BILINGUAL = 'NATIVE_BILINGUAL', +} + +export enum ContractType { + PERMANENT_CONTRACT = 'PERMANENT_CONTRACT', + SELF_EMPLOYED = 'SELF_EMPLOYED', + FREELANCE = 'FREELANCE', + FIXED_TERM_CONTRACT = 'FIXED_TERM_CONTRACT', + INTERNSHIP = 'INTERNSHIP', + APPRENTICESHIP = 'APPRENTICESHIP', + PERFORMING_ARTS_INTERMITTENT = 'PERFORMING_ARTS_INTERMITTENT', + PART_TIME_PERMANENT = 'PART_TIME_PERMANENT', + CIVIC_SERVICE = 'CIVIC_SERVICE', + PART_TIME_FIXED_TERM = 'PART_TIME_FIXED_TERM', + SUPPORTED_EMPLOYMENT = 'SUPPORTED_EMPLOYMENT', + CIVIL_SERVANT = 'CIVIL_SERVANT', + TEMPORARY_WORKER = 'TEMPORARY_WORKER', + ASSOCIATIVE = 'ASSOCIATIVE', +} + +// LinkedIn specific types +export interface LinkedInData { + name: string; + headline: string; + location: string; + connections: number; + profileUrl: string; + accountCreationDate?: string; + experience: LinkedInExperience[]; + education: LinkedInEducation[]; + skills: string[]; + activity: LinkedInActivity; + recommendations?: LinkedInRecommendation[]; + certifications?: LinkedInCertification[]; +} + +export interface LinkedInExperience { + company: string; + title: string; + duration: string; + location?: string; + description?: string; + companyExists?: boolean; // Whether company page exists on LinkedIn +} + +export interface LinkedInEducation { + school: string; + degree: string; + years: string; + location?: string; + description?: string; + schoolExists?: boolean; // Whether school page exists on LinkedIn +} + +export interface LinkedInActivity { + posts: number; + likes: number; + comments: number; + shares?: number; + lastActivityDate?: string; +} + +export interface LinkedInRecommendation { + recommender: string; + recommenderTitle?: string; + recommenderCompany?: string; + text: string; + date?: string; + recommenderProfileExists?: boolean; +} + +export interface LinkedInCertification { + name: string; + issuer: string; + issueDate?: string; + expirationDate?: string; + credentialId?: string; + credentialUrl?: string; +} + export interface Applicant { id: string; name: string; diff --git a/frontend/src/lib/interfaces/cv.ts b/frontend/src/lib/interfaces/cv.ts deleted file mode 100644 index c789867..0000000 --- a/frontend/src/lib/interfaces/cv.ts +++ /dev/null @@ -1,367 +0,0 @@ -export interface ProfileData { - lastName: string - firstName: string - address: string - email: string - phone: string - linkedin: string - github: string - personalWebsite: string - professionalSummary: string - jobTitle: string - professionalExperiences: Experience[] - otherExperiences: Experience[] - educations: Education[] - skills: string[] - languages: Language[] - publications: string[] - distinctions: string[] - hobbies: string[] - references: string[] - certifications: Certification[] - other: Record // Flexible field for any additional data - source: 'cv' | 'linkedin' // Track the source of this profile data -} - -// Backward compatibility alias -export type CvData = ProfileData; - -export interface Certification { - title: string - issuer: string - issuedYear: number - issuedMonth?: number // Optional month (1-12) -} - -export interface Experience { - companyName?: string - title?: string - location: string - type: ContractType - startYear: number - startMonth?: number // Optional month (1-12) - endYear?: number // Optional if ongoing - endMonth?: number // Optional month (1-12) - ongoing: boolean - description: string - associatedSkills: string[] -} - -export interface Education { - degree: string - institution: string - location: string - startYear: number - startMonth?: number // Optional month (1-12) - endYear?: number // Optional if ongoing - endMonth?: number // Optional month (1-12) - ongoing: boolean - description: string - associatedSkills: string[] -} - -export interface Language { - language: string - level: LanguageLevel -} - -export enum LanguageLevel { - BASIC_KNOWLEDGE = 'BASIC_KNOWLEDGE', - LIMITED_PROFESSIONAL = 'LIMITED_PROFESSIONAL', - PROFESSIONAL = 'PROFESSIONAL', - FULL_PROFESSIONAL = 'FULL_PROFESSIONAL', - NATIVE_BILINGUAL = 'NATIVE_BILINGUAL', -} - -export enum ContractType { - PERMANENT_CONTRACT = 'PERMANENT_CONTRACT', - SELF_EMPLOYED = 'SELF_EMPLOYED', - FREELANCE = 'FREELANCE', - FIXED_TERM_CONTRACT = 'FIXED_TERM_CONTRACT', - INTERNSHIP = 'INTERNSHIP', - APPRENTICESHIP = 'APPRENTICESHIP', - PERFORMING_ARTS_INTERMITTENT = 'PERFORMING_ARTS_INTERMITTENT', - PART_TIME_PERMANENT = 'PART_TIME_PERMANENT', - CIVIC_SERVICE = 'CIVIC_SERVICE', - PART_TIME_FIXED_TERM = 'PART_TIME_FIXED_TERM', - SUPPORTED_EMPLOYMENT = 'SUPPORTED_EMPLOYMENT', - CIVIL_SERVANT = 'CIVIL_SERVANT', - TEMPORARY_WORKER = 'TEMPORARY_WORKER', - ASSOCIATIVE = 'ASSOCIATIVE', -} - -export interface LinkedInData { - name: string; - headline: string; - location: string; - connections: number; - profileUrl: string; - accountCreationDate?: string; - experience: LinkedInExperience[]; - education: LinkedInEducation[]; - skills: string[]; - activity: LinkedInActivity; - recommendations?: LinkedInRecommendation[]; - certifications?: LinkedInCertification[]; -} - -export interface LinkedInExperience { - company: string; - title: string; - duration: string; - location?: string; - description?: string; - companyExists?: boolean; // Whether company page exists on LinkedIn -} - -export interface LinkedInEducation { - school: string; - degree: string; - years: string; - location?: string; - description?: string; - schoolExists?: boolean; // Whether school page exists on LinkedIn -} - -export interface LinkedInActivity { - posts: number; - likes: number; - comments: number; - shares?: number; - lastActivityDate?: string; -} - -export interface LinkedInRecommendation { - recommender: string; - recommenderTitle?: string; - recommenderCompany?: string; - text: string; - date?: string; - recommenderProfileExists?: boolean; -} - -export interface LinkedInCertification { - name: string; - issuer: string; - issueDate?: string; - expirationDate?: string; - credentialId?: string; - credentialUrl?: string; -} - -export interface GitHubData { - username: string; - name: string; - bio: string; - location: string; - email: string; - blog: string; - company: string; - profileUrl: string; - avatarUrl: string; - followers: number; - following: number; - publicRepos: number; - publicGists: number; - accountCreationDate: string; - lastActivityDate?: string; - repositories: GitHubRepository[]; - repositoryContent?: GitHubRepositoryContent[]; // Content analysis for each repo - languages: GitHubLanguageStats[]; - contributions: GitHubContributionStats; - activityAnalysis?: GitHubActivityAnalysis; - starredRepos: number; - forkedRepos: number; - organizations: GitHubOrganization[]; - overallQualityScore?: GitHubQualityScore; - other: Record; -} - -export interface GitHubRepository { - name: string; - fullName: string; - description: string; - language: string; - stars: number; - forks: number; - watchers: number; - size: number; - isPrivate: boolean; - isFork: boolean; - createdAt: string; - updatedAt: string; - topics: string[]; - url: string; - cloneUrl: string; - license?: string; - hasIssues: boolean; - hasProjects: boolean; - hasWiki: boolean; - hasPages: boolean; - openIssues: number; - defaultBranch: string; -} - -export interface GitHubLanguageStats { - language: string; - percentage: number; - bytes: number; -} - -export interface GitHubContributionStats { - totalCommits: number; - totalPullRequests: number; - totalIssues: number; - totalRepositories: number; - streakDays: number; - contributionsLastYear: number; - mostActiveDay?: string; - mostUsedLanguage?: string; -} - -export interface GitHubOrganization { - login: string; - name: string; - description: string; - url: string; - avatarUrl: string; - publicRepos: number; - location: string; - blog: string; - email: string; - createdAt: string; -} - -export interface GitHubRepositoryContent { - readme: GitHubReadmeAnalysis; - packageJson?: GitHubPackageAnalysis; - workflows: GitHubWorkflowAnalysis[]; - codeStructure: GitHubCodeStructure; - qualityScore: GitHubQualityScore; -} - -export interface GitHubReadmeAnalysis { - exists: boolean; - length: number; - sections: string[]; - hasBadges: boolean; - hasInstallInstructions: boolean; - hasUsageExamples: boolean; - hasContributing: boolean; - hasLicense: boolean; - imageCount: number; - linkCount: number; - codeBlockCount: number; - qualityScore: number; // 0-100 -} - -export interface GitHubPackageAnalysis { - exists: boolean; - hasScripts: boolean; - scriptCount: number; - dependencyCount: number; - devDependencyCount: number; - hasLinting: boolean; - hasTesting: boolean; - hasTypeScript: boolean; - hasDocumentation: boolean; - hasValidLicense: boolean; - frameworks?: string[]; // Detected frameworks (React, Vue, Angular, etc.) - buildTools?: string[]; // Build tools (webpack, rollup, vite, etc.) - testingFrameworks?: string[]; // Testing frameworks (jest, mocha, cypress, etc.) - lintingTools?: string[]; // Linting tools (eslint, prettier, etc.) - outdatedDependencies?: number; - securityVulnerabilities?: number; -} - -export interface GitHubWorkflowAnalysis { - name: string; - fileName: string; - triggers: string[]; - jobs: string[]; - hasTestJob: boolean; - hasLintJob: boolean; - hasBuildJob: boolean; - hasDeployJob: boolean; - usesSecrets: boolean; - matrixStrategy: boolean; - complexity: number; // 0-100 -} - -export interface GitHubCodeStructure { - fileCount: number; - directoryCount: number; - languageFiles: Record; - hasTests: boolean; - testFrameworks: string[]; - hasDocumentation: boolean; - hasExamples: boolean; - hasConfigFiles: boolean; - organizationScore: number; // 0-100 -} - -export interface GitHubQualityScore { - overall: number; // 0-100 - readme: number; - codeOrganization: number; - cicd: number; - documentation: number; - maintenance: number; - community: number; - breakdown: { - readmeQuality: number; - hasCI: number; - hasTests: number; - hasLinting: number; - dependencyHealth: number; - communityFiles: number; - recentActivity: number; - }; -} - -export interface GitHubActivityAnalysis { - commitFrequency: GitHubCommitFrequency; - issueMetrics: GitHubIssueMetrics; - pullRequestMetrics: GitHubPullRequestMetrics; - collaborationSignals: GitHubCollaborationSignals; -} - -export interface GitHubCommitFrequency { - lastWeek: number; - lastMonth: number; - lastYear: number; - averagePerWeek: number; - commitMessageQuality: number; // 0-100 - conventionalCommits: boolean; -} - -export interface GitHubIssueMetrics { - totalOpen: number; - totalClosed: number; - averageResponseTime: number; // hours - averageResolutionTime: number; // hours - hasLabels: boolean; - hasTemplates: boolean; - maintainerResponseRate: number; // 0-100 -} - -export interface GitHubPullRequestMetrics { - totalOpen: number; - totalMerged: number; - averageReviewTime: number; // hours - averageMergeTime: number; // hours - hasTemplates: boolean; - requiresReviews: boolean; - maintainerMergeRate: number; // 0-100 -} - -export interface GitHubCollaborationSignals { - uniqueContributors: number; - coreTeamSize: number; - outsideContributions: number; - forkToStarRatio: number; - communityEngagement: number; // 0-100 - hasCodeOfConduct: boolean; - hasContributingGuide: boolean; - hasSecurityPolicy: boolean; -} diff --git a/frontend/src/lib/interfaces/github.ts b/frontend/src/lib/interfaces/github.ts deleted file mode 100644 index a5e5c9d..0000000 --- a/frontend/src/lib/interfaces/github.ts +++ /dev/null @@ -1,216 +0,0 @@ -export interface GitHubData { - username: string; - name: string; - bio: string; - location: string; - email: string; - blog: string; - company: string; - profileUrl: string; - avatarUrl: string; - followers: number; - following: number; - publicRepos: number; - publicGists: number; - accountCreationDate: string; - lastActivityDate?: string; - repositories: GitHubRepository[]; - repositoryContent?: GitHubRepositoryContent[]; // Content analysis for each repo - languages: GitHubLanguageStats[]; - contributions: GitHubContributionStats; - activityAnalysis?: GitHubActivityAnalysis; - starredRepos: number; - forkedRepos: number; - organizations: GitHubOrganization[]; - overallQualityScore?: GitHubQualityScore; - other: Record; - } - - export interface GitHubRepository { - name: string; - fullName: string; - description: string; - language: string; - stars: number; - forks: number; - watchers: number; - size: number; - isPrivate: boolean; - isFork: boolean; - createdAt: string; - updatedAt: string; - topics: string[]; - url: string; - cloneUrl: string; - license?: string; - hasIssues: boolean; - hasProjects: boolean; - hasWiki: boolean; - hasPages: boolean; - openIssues: number; - defaultBranch: string; - } - - export interface GitHubLanguageStats { - language: string; - percentage: number; - bytes: number; - } - - export interface GitHubContributionStats { - totalCommits: number; - totalPullRequests: number; - totalIssues: number; - totalRepositories: number; - streakDays: number; - contributionsLastYear: number; - mostActiveDay?: string; - mostUsedLanguage?: string; - } - - export interface GitHubOrganization { - login: string; - name: string; - description: string; - url: string; - avatarUrl: string; - publicRepos: number; - location: string; - blog: string; - email: string; - createdAt: string; - } - - export interface GitHubRepositoryContent { - readme: GitHubReadmeAnalysis; - packageJson?: GitHubPackageAnalysis; - workflows: GitHubWorkflowAnalysis[]; - codeStructure: GitHubCodeStructure; - qualityScore: GitHubQualityScore; - } - - export interface GitHubReadmeAnalysis { - exists: boolean; - length: number; - sections: string[]; - hasBadges: boolean; - hasInstallInstructions: boolean; - hasUsageExamples: boolean; - hasContributing: boolean; - hasLicense: boolean; - imageCount: number; - linkCount: number; - codeBlockCount: number; - qualityScore: number; // 0-100 - } - - export interface GitHubPackageAnalysis { - exists: boolean; - hasScripts: boolean; - scriptCount: number; - dependencyCount: number; - devDependencyCount: number; - hasLinting: boolean; - hasTesting: boolean; - hasTypeScript: boolean; - hasDocumentation: boolean; - hasValidLicense: boolean; - frameworks?: string[]; // Detected frameworks (React, Vue, Angular, etc.) - buildTools?: string[]; // Build tools (webpack, rollup, vite, etc.) - testingFrameworks?: string[]; // Testing frameworks (jest, mocha, cypress, etc.) - lintingTools?: string[]; // Linting tools (eslint, prettier, etc.) - outdatedDependencies?: number; - securityVulnerabilities?: number; - } - - export interface GitHubWorkflowAnalysis { - name: string; - fileName: string; - triggers: string[]; - jobs: string[]; - hasTestJob: boolean; - hasLintJob: boolean; - hasBuildJob: boolean; - hasDeployJob: boolean; - usesSecrets: boolean; - matrixStrategy: boolean; - complexity: number; // 0-100 - } - - export interface GitHubCodeStructure { - fileCount: number; - directoryCount: number; - languageFiles: Record; - hasTests: boolean; - testFrameworks: string[]; - hasDocumentation: boolean; - hasExamples: boolean; - hasConfigFiles: boolean; - organizationScore: number; // 0-100 - } - - export interface GitHubQualityScore { - overall: number; // 0-100 - readme: number; - codeOrganization: number; - cicd: number; - documentation: number; - maintenance: number; - community: number; - breakdown: { - readmeQuality: number; - hasCI: number; - hasTests: number; - hasLinting: number; - dependencyHealth: number; - communityFiles: number; - recentActivity: number; - }; - } - - export interface GitHubActivityAnalysis { - commitFrequency: GitHubCommitFrequency; - issueMetrics: GitHubIssueMetrics; - pullRequestMetrics: GitHubPullRequestMetrics; - collaborationSignals: GitHubCollaborationSignals; - } - - export interface GitHubCommitFrequency { - lastWeek: number; - lastMonth: number; - lastYear: number; - averagePerWeek: number; - commitMessageQuality: number; // 0-100 - conventionalCommits: boolean; - } - - export interface GitHubIssueMetrics { - totalOpen: number; - totalClosed: number; - averageResponseTime: number; // hours - averageResolutionTime: number; // hours - hasLabels: boolean; - hasTemplates: boolean; - maintainerResponseRate: number; // 0-100 - } - - export interface GitHubPullRequestMetrics { - totalOpen: number; - totalMerged: number; - averageReviewTime: number; // hours - averageMergeTime: number; // hours - hasTemplates: boolean; - requiresReviews: boolean; - maintainerMergeRate: number; // 0-100 - } - - export interface GitHubCollaborationSignals { - uniqueContributors: number; - coreTeamSize: number; - outsideContributions: number; - forkToStarRatio: number; - communityEngagement: number; // 0-100 - hasCodeOfConduct: boolean; - hasContributingGuide: boolean; - hasSecurityPolicy: boolean; - } \ No newline at end of file diff --git a/frontend/src/lib/interfaces/index.ts b/frontend/src/lib/interfaces/index.ts index 5593b7b..8953820 100644 --- a/frontend/src/lib/interfaces/index.ts +++ b/frontend/src/lib/interfaces/index.ts @@ -1,5 +1,8 @@ -// CV-related interfaces and enums -export * from './cv'; - -// Applicant-related interfaces +// Applicant-related interfaces (includes CV/profile types) export * from './applicant'; + +// GitHub-related interfaces +export * from './github'; + +// Analysis-related interfaces +export * from './analysis'; diff --git a/frontend/src/lib/linkedin-api.ts b/frontend/src/lib/linkedin-api.ts index 136ec82..fc932ed 100644 --- a/frontend/src/lib/linkedin-api.ts +++ b/frontend/src/lib/linkedin-api.ts @@ -1,4 +1,4 @@ -import { CvData, Experience, Language, ContractType } from './interfaces/cv'; +import { CvData, Experience, Language, ContractType, LanguageLevel } from './interfaces/applicant'; // LinkedIn API Response interfaces interface LinkedInApiExperience { @@ -109,7 +109,7 @@ export function convertLinkedInApiToProfileData(linkedinApiData: LinkedInApiResp // Convert languages - simplified for now const languages: Language[] = (data.languages || []).map((lang: LinkedInApiLanguage) => ({ language: lang.title || '', - level: 'PROFESSIONAL' // Default level + level: LanguageLevel.PROFESSIONAL // Default level })); // Extract skills from various sources @@ -300,7 +300,10 @@ export async function processLinkedInUrl( // Poll for results with progress updates const results = await pollForLinkedInResults(snapshotId, apiKey, (pollProgress) => { onProgress?.({ - ...pollProgress, + attempt: pollProgress.attempt, + maxAttempts: pollProgress.maxAttempts, + status: pollProgress.status as 'starting' | 'polling' | 'running' | 'ready' | 'retrying' | 'error', + message: pollProgress.message, percentage: 10 + (pollProgress.attempt / pollProgress.maxAttempts) * 80 // 10% to 90% }); }); @@ -318,7 +321,7 @@ export async function processLinkedInUrl( }); // Convert the first result to our ProfileData format - const profileData = convertLinkedInApiToProfileData(results[0]); + const profileData = convertLinkedInApiToProfileData(results[0] as LinkedInApiResponse); onProgress?.({ attempt: 30, diff --git a/frontend/src/lib/cv.ts b/frontend/src/lib/profile-pdf.ts similarity index 99% rename from frontend/src/lib/cv.ts rename to frontend/src/lib/profile-pdf.ts index 5e24a7d..6df0822 100644 --- a/frontend/src/lib/cv.ts +++ b/frontend/src/lib/profile-pdf.ts @@ -1,4 +1,4 @@ -import { CvData, ContractType, LanguageLevel, Experience, Language } from './interfaces/cv' +import { CvData, ContractType, LanguageLevel } from './interfaces/applicant' import { Groq } from 'groq-sdk' import * as fs from 'fs' import * as path from 'path' diff --git a/frontend/src/lib/simple_tests/batch-process.ts b/frontend/src/lib/simple_tests/batch-process.ts index 85fedf5..ecbbca9 100644 --- a/frontend/src/lib/simple_tests/batch-process.ts +++ b/frontend/src/lib/simple_tests/batch-process.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Applicant } from '../interfaces/applicant'; -import type { CvData } from '../interfaces/cv'; +import type { CvData } from '../interfaces/applicant'; import type { GitHubData } from '../interfaces/github'; interface CsvRow { @@ -140,7 +140,7 @@ async function processApplicantData(applicantId: string, row: CsvRow): Promise diff --git a/frontend/src/lib/simple_tests/test-cv.ts b/frontend/src/lib/simple_tests/test-cv.ts index 4a3ece6..540f17a 100644 --- a/frontend/src/lib/simple_tests/test-cv.ts +++ b/frontend/src/lib/simple_tests/test-cv.ts @@ -1,5 +1,5 @@ -import { processCvPdf } from '../cv.js' -import { Experience } from '../interfaces/cv' +import { processCvPdf } from '../profile-pdf' +import { Experience } from '../interfaces/applicant' import * as path from 'path' /** From e38cbd377c6a2acf39538a7c6c27086ff8f479c8 Mon Sep 17 00:00:00 2001 From: Albin Jaldevik Date: Wed, 16 Jul 2025 10:21:55 +0200 Subject: [PATCH 011/211] update env example --- frontend/.env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/.env.example b/frontend/.env.example index 4281dbd..eb0d2ea 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -15,4 +15,3 @@ NEXT_PUBLIC_APP_ENV= # Local Supabase configuration NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= From f994d4a586f34092cd85f81e01634356f45dccd4 Mon Sep 17 00:00:00 2001 From: Albin Jaldevik Date: Wed, 16 Jul 2025 10:28:32 +0200 Subject: [PATCH 012/211] add prod env to gitignore --- frontend/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/.gitignore b/frontend/.gitignore index 51bf37b..d756382 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* # local env files .env*.local .env +.env.production # vercel .vercel From 03215ee8d7cb4e89ce600d13066e0482508c951b Mon Sep 17 00:00:00 2001 From: Albin Jaldevik Date: Wed, 16 Jul 2025 10:37:32 +0200 Subject: [PATCH 013/211] chore: replace img with next Image --- frontend/src/app/auth/auth-code-error/page.tsx | 3 ++- .../app/board/components/ConsoleSidebar.tsx | 5 ++++- frontend/src/app/login/page.tsx | 3 ++- frontend/src/app/page.tsx | 5 ++++- frontend/src/components/BoardSidebar.tsx | 9 +++++++-- frontend/src/components/Features.tsx | 18 ++++++++++++++---- .../src/components/NerdBusterHeaderLogo.tsx | 5 ++++- frontend/src/components/Testimonials.tsx | 9 +++++++-- 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/auth/auth-code-error/page.tsx b/frontend/src/app/auth/auth-code-error/page.tsx index 57f1459..c5a7130 100644 --- a/frontend/src/app/auth/auth-code-error/page.tsx +++ b/frontend/src/app/auth/auth-code-error/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import Image from 'next/image'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -8,7 +9,7 @@ export default function AuthCodeErrorPage() {
- Unmask + Unmask

Authentication Error

diff --git a/frontend/src/app/board/components/ConsoleSidebar.tsx b/frontend/src/app/board/components/ConsoleSidebar.tsx index 5619f14..7606524 100644 --- a/frontend/src/app/board/components/ConsoleSidebar.tsx +++ b/frontend/src/app/board/components/ConsoleSidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { Trash2, LayoutDashboard, Users, Settings, CreditCard, Cog, ChevronRight } from 'lucide-react'; +import Image from 'next/image'; import { Button } from '../../../components/ui/button'; import { useApplicants } from '../../../lib/contexts/ApplicantContext'; import Link from 'next/link'; @@ -65,9 +66,11 @@ export default function ConsoleSidebar({ {/* Logo Header */}

- Unmask diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 31f1688..33c7f8b 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import Image from 'next/image'; import { useAuth } from '@/lib/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -46,7 +47,7 @@ export default function LoginPage() {
- Unmask + Unmask

{isSignUp ? 'Create Account' : 'Welcome Back'} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9803cec..63ba3cb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Image from "next/image"; import { Input } from "@/components/ui/input"; import Features from "@/components/Features"; import Testimonials from "@/components/Testimonials"; @@ -108,9 +109,11 @@ export default function Home() { {/* UI Preview - desktop only */}
- Unmask UI Preview
diff --git a/frontend/src/components/BoardSidebar.tsx b/frontend/src/components/BoardSidebar.tsx index 6d4bc30..736f37e 100644 --- a/frontend/src/components/BoardSidebar.tsx +++ b/frontend/src/components/BoardSidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import Image from 'next/image'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState, useCallback, memo, useMemo, useEffect } from 'react'; import { LayoutDashboard, Users, Plus, ChevronDown, Settings, Check, Search, LogOut } from 'lucide-react'; @@ -183,9 +184,11 @@ const BoardSidebarComponent = ({ isCollapsed, onToggle }: BoardSidebarProps) =>
{isCollapsed ? (
- Unmask