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
52 changes: 52 additions & 0 deletions backend/scripts/fix_follow_up_status.js
Original file line number Diff line number Diff line change
@@ -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();
84 changes: 84 additions & 0 deletions backend/scripts/sync_applications_history.js
Original file line number Diff line number Diff line change
@@ -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();
18 changes: 17 additions & 1 deletion backend/services/applications.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
54 changes: 27 additions & 27 deletions backend/services/email-classifier.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}

Expand Down
41 changes: 23 additions & 18 deletions backend/services/mail-poller.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,29 @@ 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;
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.
};

/**
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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)) {
Expand All @@ -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';
Expand Down
Loading
Loading