diff --git a/backend/scripts/fix_follow_up_status.js b/backend/scripts/fix_follow_up_status.js new file mode 100644 index 0000000..e147a8d --- /dev/null +++ b/backend/scripts/fix_follow_up_status.js @@ -0,0 +1,52 @@ +require('dotenv').config(); +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); +const { adminSupabase } = require('../supabaseClient'); + +async function migrate() { + console.log("Starting migration to remove 'follow_up' status..."); + + // Update applications table + const { data: apps, error: appError } = await adminSupabase + .from('applications') + .update({ status: 'Applied' }) + .ilike('status', 'follow_up') + .select(); + + if (appError) { + console.error("Error updating applications:", appError); + } else { + console.log(`Updated ${apps ? apps.length : 0} applications from 'follow_up' to 'Applied'.`); + } + + // Update application_history table new_status + const { data: hist, error: histError } = await adminSupabase + .from('application_history') + .update({ new_status: 'Applied' }) + .ilike('new_status', 'follow_up') + .select(); + + if (histError) { + console.error("Error updating application history:", histError); + } else { + console.log(`Updated ${hist ? hist.length : 0} history records from 'follow_up' to 'Applied'.`); + } + + // Update application_history table old_status + const { data: histOld, error: histOldError } = await adminSupabase + .from('application_history') + .update({ old_status: 'Applied' }) + .ilike('old_status', 'follow_up') + .select(); + + if (histOldError) { + console.error("Error updating application history old_status:", histOldError); + } else { + console.log(`Updated ${histOld ? histOld.length : 0} history records (old_status) from 'follow_up' to 'Applied'.`); + } + + console.log("Migration complete."); + process.exit(0); +} + +migrate(); diff --git a/backend/scripts/sync_applications_history.js b/backend/scripts/sync_applications_history.js new file mode 100644 index 0000000..5dcf22a --- /dev/null +++ b/backend/scripts/sync_applications_history.js @@ -0,0 +1,84 @@ +require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); +const { adminSupabase } = require('../supabaseClient'); + +async function syncApplications() { + console.log("Starting resync of applications table with application_history..."); + + const { data: applications, error: appsError } = await adminSupabase + .from('applications') + .select('*'); + + if (appsError) { + console.error("Error fetching applications:", appsError); + process.exit(1); + } + + if (!applications || applications.length === 0) { + console.log("No applications found."); + process.exit(0); + } + + console.log(`Found ${applications.length} applications. Fetching history...`); + + const { data: history, error: histError } = await adminSupabase + .from('application_history') + .select('*'); + + if (histError) { + console.error("Error fetching application history:", histError); + process.exit(1); + } + + let updatedCount = 0; + + for (const app of applications) { + // Find history for this app, sorted by event_date (or created_at) descending + const appHistory = history + .filter(h => h.application_id === app.id && h.new_status != null) + .sort((a, b) => { + const dateA = new Date(a.event_date || a.created_at || 0).toISOString().split('T')[0]; + const dateB = new Date(b.event_date || b.created_at || 0).toISOString().split('T')[0]; + if (dateB !== dateA) { + return dateB.localeCompare(dateA); + } + return b.id - a.id; + }); + + if (appHistory.length > 0) { + const latestEvent = appHistory[0]; + const trueStatus = latestEvent.new_status; + const trueStage = latestEvent.new_stage !== undefined ? latestEvent.new_stage : app.stage; + const trueDate = latestEvent.event_date || latestEvent.created_at; + const trueDateOnly = new Date(trueDate).toISOString().split('T')[0]; + const appDateOnly = app.date ? new Date(app.date).toISOString().split('T')[0] : null; + + // Check if application is out of sync + if (app.status !== trueStatus || app.stage !== trueStage || appDateOnly !== trueDateOnly) { + console.log(`Syncing app ${app.id} (${app.company}):`); + console.log(` Current: Status=${app.status}, Stage=${app.stage}, Date=${appDateOnly}`); + console.log(` History: Status=${trueStatus}, Stage=${trueStage}, Date=${trueDateOnly}`); + + const { error: updateError } = await adminSupabase + .from('applications') + .update({ + status: trueStatus, + stage: trueStage, + date: trueDateOnly + }) + .eq('id', app.id); + + if (updateError) { + console.error(` [!] Failed to update app ${app.id}:`, updateError); + } else { + console.log(` [*] Successfully resynced app ${app.id}.`); + updatedCount++; + } + } + } + } + + console.log(`\nResync complete. Fixed ${updatedCount} applications.`); + process.exit(0); +} + +syncApplications(); diff --git a/backend/services/applications.service.js b/backend/services/applications.service.js index c16fed2..e44f248 100644 --- a/backend/services/applications.service.js +++ b/backend/services/applications.service.js @@ -46,7 +46,7 @@ const createApplication = async (userId, data, supabaseClient) => { null, newApp.stage, 'Application created', - '', null, null, supabaseClient + '', null, newApp.date || null, supabaseClient ); return newApp; @@ -175,6 +175,22 @@ const bulkCreateApplications = async (userId, applications, supabaseClient) => { console.error("Bulk Insert Error:", error); throw error; } + + if (data && data.length > 0) { + for (const app of data) { + await applicationHistoryService.logChange( + app.id, + 'Application Added', + null, + app.status, + null, + app.stage, + 'Application imported', + '', null, app.date || null, supabaseClient + ); + } + } + return { success: true, count: data ? data.length : 0 }; }; const getAnalyticsMetrics = async (userId, supabaseClient) => { diff --git a/backend/services/email-classifier.service.js b/backend/services/email-classifier.service.js index 7c14f6a..ed9d08a 100644 --- a/backend/services/email-classifier.service.js +++ b/backend/services/email-classifier.service.js @@ -2,33 +2,33 @@ const stringSimilarity = require('string-similarity'); // ── Status keyword dictionaries ────────────────────────────────────────────── const STATUS_KEYWORDS = { - interview: [ + Interviewing: [ 'interview', 'schedule', 'call with', 'meet with', 'zoom link', 'teams meeting', 'phone screen', 'on-site', 'onsite', 'virtual interview', 'calendar invite', 'interview slot', 'book a time', 'availability' ], - rejected: [ + Rejected: [ 'unfortunately', 'not moving forward', 'other candidates', 'regret to inform', 'not selected', 'decided not to proceed', 'will not be advancing', 'position has been filled', 'not a fit', 'after careful consideration', 'we have decided' ], - offer: [ + Offer: [ 'offer letter', 'congratulations', 'we\'d like to offer', 'we would like to offer', 'compensation', 'start date', 'employment agreement', 'welcome aboard', 'pleased to offer' ], - assessment: [ + Assessment: [ 'assessment', 'coding challenge', 'take-home', 'technical test', 'online test', 'hackerrank', 'codility', 'leetcode', 'assignment' ], - follow_up: [ - 'following up', 'checking in', 'next steps', 'update on your application', - 'status update', 'where we are', 'wanted to let you know', 'keep you posted' + Screening: [ + 'fill in more details', 'additional details needed', 'screening', + 'quick chat', 'hr screen', 'recruiter screen', 'more information required' ], // Informational only — application received/confirmation. Never triggers a status // update or notification; used purely for conflict resolution inside detectStatus(). - applied: [ + Applied: [ 'application was sent', 'application has been submitted', 'we received your application', 'thank you for applying', 'thank you for your application', 'application received', @@ -407,24 +407,24 @@ function classifyEmail(email, applications) { /** * Detect status category from email text using keyword dictionaries. * - * Priority order: offer > rejected > interview > assessment > follow_up > applied + * Priority order: Offer > Rejected > Interviewing > Assessment > Screening > Applied * * Conflict resolution rules: - * 1. If strong CONFIRMATION_SUPPRESSORS are present, 'rejected' is suppressed + * 1. If strong CONFIRMATION_SUPPRESSORS are present, 'Rejected' is suppressed * regardless of which rejection keywords were also matched. - * 2. If both 'applied' and 'rejected' fire, the one with more keyword hits wins; - * on a tie, 'applied' wins (safer — avoids false rejections). - * 3. 'applied' is never returned as the winner if any higher-priority status + * 2. If both 'Applied' and 'Rejected' fire, the one with more keyword hits wins; + * on a tie, 'Applied' wins (safer — avoids false rejections). + * 3. 'Applied' is never returned as the winner if any higher-priority status * also fired, since confirmations sometimes mention interviews/offers. */ function detectStatus(text) { const STATUS_PRIORITY = { - offer: 100, - rejected: 80, - interview: 60, - assessment: 40, - follow_up: 20, - applied: 10, + Offer: 100, + Rejected: 80, + Interviewing: 60, + Assessment: 40, + Screening: 30, + Applied: 10, }; // Tally hits per status @@ -433,19 +433,19 @@ function detectStatus(text) { hitCounts[status] = keywords.filter(kw => text.includes(kw)).length; } - // Rule 1: Confirmation suppression — strong confirmation phrase overrides 'rejected' + // Rule 1: Confirmation suppression — strong confirmation phrase overrides 'Rejected' const hasConfirmationSignal = CONFIRMATION_SUPPRESSORS.some(phrase => text.includes(phrase)); - if (hasConfirmationSignal && hitCounts.rejected > 0) { + if (hasConfirmationSignal && hitCounts.Rejected > 0) { // Zero out rejected hits so it cannot win - hitCounts.rejected = 0; + hitCounts.Rejected = 0; } - // Rule 2: applied vs rejected conflict — more hits wins; tie goes to 'applied' - if (hitCounts.applied > 0 && hitCounts.rejected > 0) { - if (hitCounts.applied >= hitCounts.rejected) { - hitCounts.rejected = 0; + // Rule 2: Applied vs Rejected conflict — more hits wins; tie goes to 'Applied' + if (hitCounts.Applied > 0 && hitCounts.Rejected > 0) { + if (hitCounts.Applied >= hitCounts.Rejected) { + hitCounts.Rejected = 0; } else { - hitCounts.applied = 0; + hitCounts.Applied = 0; } } diff --git a/backend/services/mail-poller.service.js b/backend/services/mail-poller.service.js index f0c7df9..fd632d6 100644 --- a/backend/services/mail-poller.service.js +++ b/backend/services/mail-poller.service.js @@ -5,6 +5,7 @@ const { adminSupabase: supabase } = require('../supabaseClient'); const { classifyEmail } = require('./email-classifier.service'); const emailLogsRepo = require('../repositories/email-logs.repository'); const applicationRepo = require('../repositories/applications.repository'); +const applicationsService = require('./applications.service'); const notificationsRepo = require('../repositories/notifications.repository'); const AUTO_UPDATE_THRESHOLD = 0.7; @@ -12,21 +13,21 @@ const MAX_BODY_SNIPPET = 3000; // Statuses that must never be overwritten by automated email classification. // Once an application reaches one of these states, only the user can change it. -const TERMINAL_STATUSES = new Set(['rejected', 'offer', 'accepted', 'declined']); +const TERMINAL_STATUSES = new Set(['Rejected', 'Offer', 'Hired', 'Withdrawn', 'Ignored']); // Statuses that are purely informational — they are logged but must not // trigger a DB status update or an in-app notification. -const SILENT_STATUSES = new Set(['applied', 'unknown']); +const SILENT_STATUSES = new Set(['Applied', 'Unknown', 'unknown']); // Allowed status progressions. An auto-update is only applied when the // classified status is a valid next step from the current application status. // Anything not listed here (or where current status is TERMINAL) is blocked. const STATUS_TRANSITIONS = { - applied: new Set(['assessment', 'interview', 'follow_up', 'rejected', 'offer']), - follow_up: new Set(['assessment', 'interview', 'rejected', 'offer']), - assessment: new Set(['interview', 'rejected', 'offer']), - interview: new Set(['rejected', 'offer']), - // 'offer', 'rejected', 'accepted', 'declined' are terminal — no transitions allowed. + 'Applied': new Set(['Assessment', 'Screening', 'Interviewing', 'Rejected', 'Offer']), + 'Screening': new Set(['Assessment', 'Interviewing', 'Rejected', 'Offer']), + 'Assessment': new Set(['Interviewing', 'Rejected', 'Offer']), + 'Interviewing': new Set(['Rejected', 'Offer']), + // 'Offer', 'Rejected', 'Hired', 'Withdrawn', 'Ignored' are terminal — no transitions allowed. }; /** @@ -114,14 +115,17 @@ async function pollUserInbox(settings) { } // Fetch user's applications for classification - const { data: applications } = await applicationRepo.findAll(userId, supabase); - if (!applications || applications.length === 0) { + const { data: allApplications } = await applicationRepo.findAll(userId, supabase); + if (!allApplications || allApplications.length === 0) { console.log(`[MAIL POLLER] User ${userId} has no applications, skipping classification`); // Still update polled timestamp await updatePolledTimestamp(userId, newLastUid); return; } + // Only pass active applications to the classifier so it won't match old rejected ones + const applications = allApplications.filter(app => !TERMINAL_STATUSES.has(app.status)); + // Fetch messages const messages = []; @@ -203,8 +207,8 @@ async function pollUserInbox(settings) { result.confidence >= AUTO_UPDATE_THRESHOLD && !SILENT_STATUSES.has(result.classifiedStatus) ) { - const currentApp = applications.find(a => a.id === result.applicationId); - const currentStatus = currentApp?.status || 'applied'; + const currentApp = allApplications.find(a => a.id === result.applicationId); + const currentStatus = currentApp?.status || 'Applied'; // Guard 1: Never overwrite a terminal status if (TERMINAL_STATUSES.has(currentStatus)) { @@ -213,19 +217,20 @@ async function pollUserInbox(settings) { } else if (STATUS_TRANSITIONS[currentStatus] && !STATUS_TRANSITIONS[currentStatus].has(result.classifiedStatus)) { console.log(`[MAIL POLLER] Skipping auto-update for app ${result.applicationId}: transition "${currentStatus}" → "${result.classifiedStatus}" is not allowed`); } else { - await applicationRepo.update(userId, result.applicationId, { + await applicationsService.updateApplication(userId, result.applicationId, { status: result.classifiedStatus, - date: receivedAt.toISOString().split('T')[0] + event_date: receivedAt.toISOString().split('T')[0], + notes: `Status auto-updated based on email from ${from}.` }, supabase); console.log(`[MAIL POLLER] Auto-updated application ${result.applicationId} → ${result.classifiedStatus} (confidence: ${result.confidence.toFixed(2)})`); // Create in-app notification const statusLabels = { - interview: 'Interview Scheduled', - rejected: 'Application Rejected', - offer: 'Offer Received', - assessment: 'Assessment Requested', - follow_up: 'Follow-up Received', + Interviewing: 'Interview Scheduled', + Rejected: 'Application Rejected', + Offer: 'Offer Received', + Assessment: 'Assessment Requested', + Screening: 'Screening Scheduled', }; const label = statusLabels[result.classifiedStatus] || result.classifiedStatus; const company = result.matchedCompany || 'Unknown Company'; diff --git a/backend/tests/email-classifier.test.js b/backend/tests/email-classifier.test.js index 993c891..ae061c8 100644 --- a/backend/tests/email-classifier.test.js +++ b/backend/tests/email-classifier.test.js @@ -142,41 +142,41 @@ describe('extractCompanyFromSubject()', () => { // ───────────────────────────────────────────────────────────────────────────── describe('detectStatus()', () => { - test('Application confirmation email → "applied" (not "rejected")', () => { + test('Application confirmation email → "Applied" (not "Rejected")', () => { const text = 'thank you for your application we received your application confirmation'; - expect(detectStatus(text)).toBe('applied'); + expect(detectStatus(text)).toBe('Applied'); }); test('"Thank you for your application" suppresses rejection keywords', () => { // Even if a rejection keyword sneaks in, confirmation suppressor wins const text = 'thank you for your application after careful consideration we have decided'; - expect(detectStatus(text)).toBe('applied'); + expect(detectStatus(text)).toBe('Applied'); }); - test('Clear rejection without confirmation signal → "rejected"', () => { + test('Clear rejection without confirmation signal → "Rejected"', () => { const text = 'unfortunately we will not be moving forward with other candidates at this time'; - expect(detectStatus(text)).toBe('rejected'); + expect(detectStatus(text)).toBe('Rejected'); }); - test('Salesforce rejection body → "rejected"', () => { + test('Salesforce rejection body → "Rejected"', () => { const text = `the software engineer new graduate full-stack position has been filled and is now closed`; // "position has been filled" is a rejection keyword - expect(detectStatus(text)).toBe('rejected'); + expect(detectStatus(text)).toBe('Rejected'); }); - test('Interview invite → "interview"', () => { + test('Interview invite → "Interviewing"', () => { const text = "we'd like to schedule an interview with you for a phone screen next week"; - expect(detectStatus(text)).toBe('interview'); + expect(detectStatus(text)).toBe('Interviewing'); }); - test('Offer letter → "offer" (highest priority)', () => { + test('Offer letter → "Offer" (highest priority)', () => { const text = 'we are pleased to offer you employment agreement welcome aboard start date monday'; - expect(detectStatus(text)).toBe('offer'); + expect(detectStatus(text)).toBe('Offer'); }); - test('Assessment request → "assessment"', () => { + test('Assessment request → "Assessment"', () => { const text = 'please complete the following coding challenge on hackerrank by friday'; - expect(detectStatus(text)).toBe('assessment'); + expect(detectStatus(text)).toBe('Assessment'); }); test('No keywords → "unknown"', () => { @@ -185,7 +185,7 @@ describe('detectStatus()', () => { test('Offer beats rejected even if both fire', () => { const text = 'unfortunately we are pleased to offer you employment agreement'; - expect(detectStatus(text)).toBe('offer'); + expect(detectStatus(text)).toBe('Offer'); }); }); @@ -268,8 +268,8 @@ describe('classifyEmail()', () => { expect(result.applicationId).toBe(1); expect(result.matchedCompany).toBe('Red Hat'); - // Confirmation email → status should be 'applied', not 'rejected' - expect(result.classifiedStatus).toBe('applied'); + // Confirmation email → status should be 'Applied', not 'Rejected' + expect(result.classifiedStatus).toBe('Applied'); }); test('[Workday] hpe@myworkday.com — job ID in subject → matches HPE app', () => { @@ -330,7 +330,7 @@ describe('classifyEmail()', () => { expect(result.applicationId).toBe(1); expect(result.matchedCompany).toBe('Jeen.ai'); - expect(result.classifiedStatus).toBe('applied'); + expect(result.classifiedStatus).toBe('Applied'); }); // ── Direct company email path ───────────────────────────────────────────── @@ -366,7 +366,7 @@ describe('classifyEmail()', () => { expect(result.applicationId).toBe(2); expect(result.matchedCompany).toBe('Microsoft'); // Application confirmation should NOT be classified as rejected - expect(result.classifiedStatus).not.toBe('rejected'); + expect(result.classifiedStatus).not.toBe('Rejected'); }); // ── Salesforce Workday rejection ────────────────────────────────────────── @@ -392,7 +392,7 @@ The Salesforce Recruiting Team`; expect(result.applicationId).toBe(1); expect(result.matchedCompany).toBe('Salesforce'); - expect(result.classifiedStatus).toBe('rejected'); + expect(result.classifiedStatus).toBe('Rejected'); }); // ── Multi-application same company disambiguation ───────────────────────── diff --git a/backend/tests/integration/application-lifecycle.integration.test.js b/backend/tests/integration/application-lifecycle.integration.test.js index 46456ab..c418559 100644 --- a/backend/tests/integration/application-lifecycle.integration.test.js +++ b/backend/tests/integration/application-lifecycle.integration.test.js @@ -34,7 +34,7 @@ describe('Integration: Application Lifecycle (Status Machine)', () => { }); expect(created.status).toBe('Applied'); expect(applicationHistoryService.logChange).toHaveBeenCalledWith( - 1, 'Application Added', null, 'Applied', null, null, 'Application created', '', null, null, undefined + 1, 'Application Added', null, 'Applied', null, null, 'Application created', '', null, '2025-01-01', undefined ); // Step 2: Update to Interviewing / HR Screen diff --git a/backend/tests/integration/email-pipeline.integration.test.js b/backend/tests/integration/email-pipeline.integration.test.js index d4afe97..4fad3db 100644 --- a/backend/tests/integration/email-pipeline.integration.test.js +++ b/backend/tests/integration/email-pipeline.integration.test.js @@ -85,10 +85,10 @@ const baseSettings = { }; const testApplications = [ - { id: 1, company: 'TestCorp', position: 'Software Engineer', status: 'applied', role_id: '' }, - { id: 2, company: 'AcmeTech', position: 'Frontend Developer', status: 'applied', role_id: 'REQ-456' }, - { id: 3, company: 'BigBank', position: 'Data Engineer', status: 'rejected', role_id: '' }, - { id: 4, company: 'StartupXYZ', position: 'Fullstack Dev', status: 'interview', role_id: '' }, + { id: 1, company: 'TestCorp', position: 'Software Engineer', status: 'Applied', role_id: '' }, + { id: 2, company: 'AcmeTech', position: 'Frontend Developer', status: 'Applied', role_id: 'REQ-456' }, + { id: 3, company: 'BigBank', position: 'Data Engineer', status: 'Rejected', role_id: '' }, + { id: 4, company: 'StartupXYZ', position: 'Fullstack Dev', status: 'Interviewing', role_id: '' }, ]; describe('Integration: Email Processing Pipeline', () => { @@ -115,8 +115,16 @@ describe('Integration: Email Processing Pipeline', () => { // email_logs.findByMessageId → no existing log sandbox.onTable('email_logs').forSelect({ data: null, error: null }); + // applicationRepo.findById + sandbox.onTable('applications').forSelect({ data: { id: 1, status: 'Applied', stage: null }, error: null }); + + // application_history (for conflicts detection and history recalculation) + sandbox.onTable('application_history').forSelect({ data: [], error: null }); + sandbox.onTable('application_history').forInsert({ data: [{ id: 10 }], error: null }); + sandbox.onTable('application_history').forSelect({ data: [{ id: 10, new_status: 'Rejected', new_stage: null }], error: null }); + // applicationRepo.update (auto-update) - sandbox.onTable('applications').forUpdate({ data: { id: 1, status: 'rejected' }, error: null }); + sandbox.onTable('applications').forUpdate({ data: { id: 1, status: 'Rejected' }, error: null }); // notifications.insert sandbox.onTable('notifications').forInsert({ data: { id: 1 }, error: null }); @@ -208,7 +216,7 @@ describe('Integration: Email Processing Pipeline', () => { await pollUserInbox(baseSettings); - // StartupXYZ app (id: 4) is at "Interviewing" — cannot go back to "assessment" + // StartupXYZ app (id: 4) is at "Interviewing" — cannot go back to "Assessment" const appUpdateCalls = sandbox.getCallsTo('applications', 'update'); expect(appUpdateCalls.length).toBe(0); }); @@ -294,7 +302,16 @@ describe('Integration: Email Processing Pipeline', () => { }); sandbox.onTable('email_logs').forSelect({ data: null, error: null }); - sandbox.onTable('applications').forUpdate({ data: { id: 1, status: 'interview' }, error: null }); + + // applicationRepo.findById + sandbox.onTable('applications').forSelect({ data: { id: 1, status: 'Applied', stage: null }, error: null }); + + // application_history + sandbox.onTable('application_history').forSelect({ data: [], error: null }); + sandbox.onTable('application_history').forInsert({ data: [{ id: 11 }], error: null }); + sandbox.onTable('application_history').forSelect({ data: [{ id: 11, new_status: 'Interviewing', new_stage: null }], error: null }); + + sandbox.onTable('applications').forUpdate({ data: { id: 1, status: 'Interviewing' }, error: null }); let capturedNotification = null; const origFrom = sandbox.from; diff --git a/backend/tests/services/applications.service.test.js b/backend/tests/services/applications.service.test.js index c2539df..3f06483 100644 --- a/backend/tests/services/applications.service.test.js +++ b/backend/tests/services/applications.service.test.js @@ -83,7 +83,7 @@ describe('applications.service', () => { // Assert expect(result).toEqual(newApp); expect(applicationHistoryService.logChange).toHaveBeenCalledWith( - 10, 'Application Added', null, 'Applied', null, null, 'Application created', '', null, null, supabaseClient + 10, 'Application Added', null, 'Applied', null, null, 'Application created', '', null, newApp.date || null, supabaseClient ); });