Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
47 changes: 47 additions & 0 deletions routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { usersRouter } from './users/usersRoutes.js';
import { usersCVsRouter } from './users_cvs/usersCVsRoutes.js';
import { usersDealbreakersRouter } from './users_dealbreakers/userDealbreakersRoutes.js';
import { matchingRouter } from './matching/matchingRoutes.js';
// import multer from 'multer';
// import { extractCvText } from './users_cvs/users_CVsMiddleware.js';

const ROUTER = express.Router();

Expand All @@ -16,4 +18,49 @@ ROUTER.use('/users_cvs', usersCVsRouter);
ROUTER.use('/users_dealbreakers', usersDealbreakersRouter);
ROUTER.use('/matching', matchingRouter);

// const upload = multer({ storage: multer.memoryStorage() }); // keep file in memory
// // this endpoint is intentionally put here to avoid auth
// // it will be moved to users_CVsRoutes after more integration testing
// ROUTER.post('/upload-cv', upload.single('cv'), async (req, res) => {
// // Validation: Check if file exists
// if (!req.file) {
// return res.status(400).json({ error: 'No file uploaded' });
// }
// // Extract Dealbreakers
// // Multer populates req.body with text fields.
// // Since we JSON.stringify'd the array on frontend, we must JSON.parse it here.
// let dealbreakers: string[] = [];
// if (req.body.dealbreakers) {
// try {
// dealbreakers = JSON.parse(req.body.dealbreakers);
// } catch (e) {
// console.warn('Failed to parse dealbreakers JSON', e);
// // Fallback: treat it as empty or single string if needed
// }
// }

// // Extract Text from CV (using middleware)
// try {
// const cvText = await extractCvText(req.file);
// console.log('Extracted CV Length:', cvText.length);
// console.log('Received Dealbreakers:', dealbreakers);
// // // TO DO? Return combined data (or save to DB)
// // res.json({
// // message: 'Bio received successfully',
// // data: {
// // cvText: cvText,
// // dealbreakers: dealbreakers,
// // },
// // });

// res.json({ cvText });
// } catch (err) {
// console.error(err);
// // Better error handling for the client
// const errorMessage =
// err instanceof Error ? err.message : 'Failed to parse CV';
// res.status(500).json({ error: errorMessage });
// }
// });

export default ROUTER;
91 changes: 68 additions & 23 deletions routes/users_cvs/usersCVsRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import supabase from '../../config/supabaseClient.js';
import multer from 'multer';
import { extractCvText } from './users_CVsMiddleware.js';
import { extractKeywordsFromCv } from '../../services/cvExtractionService.js'; // Import the new service

const upload = multer({ storage: multer.memoryStorage() }); // keep file in memory

Expand Down Expand Up @@ -45,6 +47,7 @@ usersCVsRouter.get('/', async (req, res) => {
});

// CREATE
// Handles: File Upload -> Text Extraction -> Keyword extraction (openAI) -> DB Insert
/**
* @swagger
* /users_cvs:
Expand Down Expand Up @@ -74,33 +77,75 @@ usersCVsRouter.get('/', async (req, res) => {
* 500:
* description: Server error
*/
usersCVsRouter.post('/', upload.single('cv'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
usersCVsRouter.post(
'/',
upload.single('cv'),
async (req: Request, res: Response) => {
console.log('--- CV Processing Request Started ---');

let cvText = null;
// 1. Validation
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
console.log(`File Type: ${req.file.mimetype}, Size: ${req.file.size}`);

try {
cvText = await extractCvText(req.file);
console.log('Extracted CV Text:', cvText);
// res.json({ text });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to parse CV' });
}
// Get ID securely from the token (populated by authMiddleware)
const { user_id } = req.body;
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}

// TODO: add LLM handling here
// cv_keywords should come from LLM processing instead of req.body
// Note: Supabase Auth IDs are UUID STRINGS (e.g. "a0eebc99-9c0b...")
// const user_id = user.id;
console.log(`Processing for User ID: ${user_id}`);

const { user_id, cv_keywords } = req.body;
const { data, error } = await supabase
.from('users_cvs')
.insert([{ user_id, cv_keywords }])
.select();
if (error) return res.status(500).json({ error: error.message });
res.status(201).json(data);
});
let cvText = null;

try {
// 2. Extract Plain Text (using the middleware logic)
cvText = await extractCvText(req.file);
// DEBUG: Only show first 100 chars to prove it exists without flooding console
console.log(`--- 2. Text Extracted (${cvText.length} chars) ---`);
// console.log('Preview:', cvText.substring(0, 100) + '...');

// 3. AI Keyword Extraction
console.log('--- 3. Calling AI Service ---');
const keywordsArray = await extractKeywordsFromCv(cvText);

console.log('--- 4. AI Result ---');
// console.log('Keywords:', keywordsArray);

if (keywordsArray.length === 0) {
console.warn('⚠️ WARNING: AI returned 0 keywords.');
}

// Convert array ["React", "CSS"] -> String "React, CSS" for DB storage
// (Assuming your Supabase column is text. If it's text[], remove .join)
const cv_keywords = keywordsArray.join(', ');
console.log('Keywords String for DB:', cv_keywords);

// const { user_id, cv_keywords } = req.body;
// 4. Save to Supabase
const { data, error } = await supabase
.from('users_cvs')
.insert([{ user_id, cv_keywords }])
.select();

if (error) throw error;

// 5. Response
res.status(201).json({
message: 'CV processed successfully',
record: data[0],
// Sending the array back allows the Frontend to display tags immediately
generated_tags: keywordsArray,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to parse CV' });
}
},
);

// UPDATE
/**
Expand Down
22 changes: 22 additions & 0 deletions routes/users_dealbreakers/userDealbreakersRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,29 @@ usersDealbreakersRouter.get('/', async (req, res) => {
* description: Server error
*/
usersDealbreakersRouter.post('/', async (req, res) => {
console.log('--- Deal-breakers Saving Request Started ---');
// 1. Extract the token
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Missing authorization token' });
}

// 2. Verify user with Supabase
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token);

if (authError || !user) {
return res.status(401).json({ error: 'Invalid or expired session' });
}

// 3. Extract payload (Ignore user_id in body, use the one from token)
const { user_id, dealbreakers } = req.body;

console.log(`DealBreakers reseived: ${dealbreakers}`);

// 4. Insert into DB
const { data, error } = await supabase
.from('users_dealbreakers')
.insert([{ user_id, dealbreakers }])
Expand Down
1 change: 1 addition & 0 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import swaggerUi from 'swagger-ui-express';

const app = express();

// CORS configuration - allow requests from frontend server
app.use(
cors({
origin: 'http://localhost:5173',
Expand Down
56 changes: 56 additions & 0 deletions services/cvExtractionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import OpenAI from 'openai';
import dotenv from 'dotenv';

dotenv.config();

// Ensure your .env has OPENAI_API_KEY
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});

export const extractKeywordsFromCv = async (
cvText: string,
): Promise<string[]> => {
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `You are an expert HR AI. Your task is to extract a comprehensive, inclusive list of professional keywords from a CV text to assist in job matching.

Rules:
1. Extract HARD SKILLS (e.g., Python, React, AWS).
2. Extract SOFT SKILLS (e.g., Team Leadership, Adaptability).
3. Extract DOMAIN areas (e.g., Fintech, Healthcare).
4. **INCLUSIVITY**: Infer related titles and synonyms. (e.g., if "React" is present, include "Frontend Development" and "UI Engineering").
5. Output JSON only: { "keywords": ["skill1", "skill2"] }.`,
},
{
role: 'user',
content: `Analyze this CV and extract keywords:\n\n${cvText.substring(0, 15000)}`, // Safety truncate
},
],
response_format: { type: 'json_object' },
temperature: 0.3,
});

// Use ?. to safely access properties.
// If choices[0] doesn't exist, 'content' becomes undefined instead of crashing.
const content = completion.choices[0]?.message?.content;

// Guard Clause: If content is null, undefined, or empty string, stop here.
if (!content) {
console.warn('OpenAI returned no content.');
return [];
}

// Now TypeScript knows 'content' is definitely a string
const parsed = JSON.parse(content);
return parsed.keywords || [];
} catch (error) {
console.error('OpenAI Keyword Extraction Error:', error);
// Fail gracefully: return empty array so the upload doesn't crash
return [];
}
};